Merge branch 'stable-3.1'

* stable-3.1:
  Set version to 3.1.0-rc3
  Remove robot comments without human reply from "Comment Threads" tab
  Fix selected label style
  Remove unused jsAPI from gr-diff-builder
  Remove buttons (Reply, Ack, Quote, Done) for robot comments
  Report tab change - mostly for checks plugin
  Increase padding from 4px to 8px for commit message
  Add keyup to some shortcuts to avoid multiple events for longpress
  Move externs to types folder
  Added a types.js file to support cross-module types
  Upgrade JGit to latest master revision

Change-Id: Ic4af9229dd1e9e690cf326f84f918cc07e210acf
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 419f440..b764439 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2666,6 +2666,28 @@
 	filterClass = org.anyorg.MySecureIPFilter
 ----
 
+[[filterClass.className.initParam]]filterClass.<className>.initParam::
++
+Gerrit supports customized pluggable HTTP filters as `filterClass`. This
+option allows to pass extra initialization parameters to the filter. It
+allows for multiple key/value pairs to be passed in this pattern:
++
+----
+initParam = <key>=<value>
+----
+For a comprehensive example:
++
+----
+[httpd]
+	filterClass = org.anyorg.AFilter
+	filterClass = org.anyorg.BFilter
+[filterClass "org.anyorg.AFilter"]
+	key1 = value1
+	key2 = value2
+[filterClass "org.anyorg.BFilter"]
+	key3 = value3
+----
+
 [[httpd.idleTimeout]]httpd.idleTimeout::
 +
 Maximum idle time for a connection, which roughly translates to the
@@ -3594,6 +3616,39 @@
 +
 Default is false.
 
+[[operator-alias]]
+=== Section operator alias
+
+Operator aliasing allows global aliases to be defined for query operators.
+Currently only change queries are supported. The alias name is the git
+config key name, and the operator being aliased is the git config value.
+
+For example:
+
+----
+[operator-alias "change"]
+  oldage = age
+  number = change
+----
+
+This section is particularly useful to alias operator names which may be
+long and clunky because they include a plugin name in them to a shorter
+name without the plugin name.
+
+Aliases are resolved dynamically at invocation time to any currently
+loaded versions of plugins. If the alias points to an operator provided
+by a plugin which is not currently loaded, or the plugin does not define
+the operator, then "unsupported operator" is returned to the user.
+
+Aliases will override existing operators. In the case of multiple aliases
+with the same name, the last one defined will be used.
+
+When the target of an alias doesn't exist, the operator with the name
+of the alias will be used (if present). This enables an admin to config
+the system to override a core operator with an operator provided by a
+plugin when present and otherwise fall back to the operator provided by
+core.
+
 [[pack]]
 === Section pack
 
@@ -3748,6 +3803,19 @@
 +
 Default is true.
 
+[[receive.enableInMemoryRefCache]]receive.enableInMemoryRefCache::
++
+If true, Gerrit will cache all refs advertised during push in memory and
+base later receive operations on that cache.
++
+Turning this cache off is considered experimental.
++
+This cache provides value when the ref database is slow and/or does not
+offer an inverse lookup of object ID to ref name. When RefTable is used,
+this cache can be turned off (experimental) to get speed improvements.
++
+Default is true.
+
 [[receive.enableSignedPush]]receive.enableSignedPush::
 +
 If true, server-side signed push validation is enabled.
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index edeec54..af00d1c 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -37,11 +37,9 @@
 [[core-plugins]]
 == Core Plugins
 
-Core plugins are packaged within the Gerrit war file and can easily be
-installed during the link:pgm-init.html[Gerrit initialization].
-
-The core plugins are developed and maintained by the Gerrit maintainers
-and the Gerrit community.
+link:dev-core-plugins.html[Core plugins] are packaged within the Gerrit
+war file and can easily be installed during the link:pgm-init.html[
+Gerrit initialization].
 
 Note that the documentation and configuration links in the list below are
 to the plugins' master branch. Please refer to the appropriate branch or
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
new file mode 100644
index 0000000..11027ef
--- /dev/null
+++ b/Documentation/dev-core-plugins.txt
@@ -0,0 +1,94 @@
+= Gerrit Code Review - Core Plugins
+
+[[definition]]
+== What are core plugins?
+
+Core plugins are plugins that are packaged within the Gerrit war file. This
+means during the link:pgm-init.html[Gerrit initialization] they can be easily
+installed without downloading any additional files.
+
+To make working with core plugins easy, they are linked as
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/.gitmodules[Git
+submodules] in the `gerrit` repository. E.g. this means they can be easily
+link:dev-readme.html#clone[cloned] together with Gerrit.
+
+All core plugins are developed and maintained by the
+link:dev-roles.html#maintainers[Gerrit maintainers] and everyone can
+link:dev-contributing.html[contribute] to them.
+
+Adding a new core plugin feature that is large or complex requires a
+link:dev-design-doc.html[design doc] (also see
+link:dev-contributing.html#design-driven-contribution-process[design-driven
+contribution process]). The link:dev-processes.html#steering-committee[
+engineering steering committee (ESC)] is the authority that approves the design
+docs. The ESC is also in charge of adding and removing core plugins.
+
+Non-Gerrit maintainers cannot have link:access-control.html#category_owner[
+Owner] permissions for core plugins.
+
+[[criteria]]
+=== Criteria for Core Plugins
+
+To be considered as a core plugin, a plugin must fulfill the following
+criteria:
+
+1. License:
++
+The plugin code is available under the
+link:http://www.apache.org/licenses/LICENSE-2.0[Apache License Version 2.0].
+
+2. Hosting:
++
+The plugin development is hosted on the
+link:https://gerrit-review.googlesource.com[gerrit-review] Gerrit Server.
+
+3. Scope:
++
+The plugin functionality is Gerrit-related, has a clear scope and does not
+conflict with other core plugins or existing and planned Gerrit core features.
+
+4. Relevance:
++
+The plugin functionality is relevant to a majority of the Gerrit community:
++
+--
+** An out of the box Gerrit installation would seem like it is missing
+   something if the plugin is not installed.
+** It's expected that most sites would use the plugin.
+** Multiple parties (different organizations/companies) already use the plugin
+   and agree that it should be offered as core plugin.
+** If the same or similar functionality is provided by multiple plugins,
+   the plugin is the clear recommended solution by the community.
+--
++
+Whether a plugin is relevant to a majority of the Gerrit community must be
+discussed on a case-by-case basis. In case of doubt, it's up to the
+link:dev-processes.html#steering-committee[engineering steering committee] to
+make a decision.
+
+5. Code Quality:
++
+The plugin code is mature and has a good test coverage. Maintaining the plugin
+code creates only little overhead for the Gerrit maintainers.
+
+6. Documentation:
++
+The plugin functionality is fully documented.
+
+7. Ownership:
++
+Existing plugin owners which are not Gerrit maintainers must agree to give up
+their ownership. If the current plugin owners disagree, forking the plugin is
+possible, but this should happen only in exceptional cases.
+
+[[list]]
+== Which core plugins exist?
+
+See link:config-plugins.html#core-plugins[here].
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index f4e77a8..6472f2a 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -7,7 +7,8 @@
 The Gerrit project has an engineering steering committee (ESC) that is
 in charge of:
 
-* Gerrit core (the `gerrit` project) and the core plugins
+* Gerrit core (the `gerrit` project) and the link:dev-core-plugins.html[core
+  plugins]
 * defining the project vision and the project scope
 * maintaining a roadmap, a release plan and a prioritized backlog
 * ensuring timely design reviews
@@ -294,6 +295,11 @@
 vulnerability and define action items to follow up in the
 link:https://bugs.chromium.org/p/gerrit[issue tracker].
 
+[[core-plugins]]
+== Core Plugins
+
+See link:dev-core-plugins.html[here].
+
 [[upgrading-libraries]]
 == Upgrading Libraries
 
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 02b1891..34b409c 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -5,6 +5,7 @@
 
 == Git Setup
 
+[[clone]]
 === Getting the Source
 
 Create a new client workspace:
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 09ec415..4165f93 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1501,6 +1501,90 @@
   change is new
 ----
 
+[[revert-submission]]
+=== Revert Submission
+--
+'POST /changes/link:#change-id[\{change-id\}]/revert_submission'
+--
+
+Creates open revert changes for all of the changes of a certain submission.
+
+Details for the revert can be specified in the request body inside a link:#revert-input[
+RevertInput] The topic of all created revert changes will be
+`revert-{submission_id}-{random_string_of_size_10}`.
+
+The changes will not be rebased on onto the destination branch so the users may still
+have to manually rebase them to resolve conflicts and make them submittable.
+
+.Request
+----
+  POST /changes/myProject~master~I1ffe09a505e25f15ce1521bcfb222e51e62c2a14/revert_submission HTTP/1.0
+----
+
+As response link:#revert-submission-info[RevertSubmissionInfo] entity
+is returned. That entity describes the revert changes.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "revert_changes":
+    [
+      {
+        "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+        "project": "myProject",
+        "branch": "master",
+        "topic": "revert--1571043962462-3640749-ABCEEZGHIJ",
+        "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+        "subject": "Revert \"Implementing Feature X\"",
+        "status": "NEW",
+        "created": "2013-02-01 09:59:32.126000000",
+        "updated": "2013-02-21 11:16:36.775000000",
+        "mergeable": true,
+        "insertions": 6,
+        "deletions": 4,
+        "_number": 3965,
+        "owner": {
+          "name": "John Doe"
+        }
+      },
+      {
+        "id": "anyProject~master~1eee2c9d8f352483781e772f35dc586a69ff5646",
+        "project": "anyProject",
+        "branch": "master",
+        "topic": "revert--1571043962462-3640749-ABCEEZGHIJ",
+        "change_id": "I1eee2c9d8f352483781e772f35dc586a69ff5646",
+        "subject": "Revert \"Implementing Feature Y\"",
+        "status": "NEW",
+        "created": "2013-02-04 09:59:33.126000000",
+        "updated": "2013-02-21 11:16:37.775000000",
+        "mergeable": true,
+        "insertions": 62,
+        "deletions": 11,
+        "_number": 3966,
+        "owner": {
+          "name": "Jane Doe"
+        }
+      }
+    ]
+----
+
+If any of the changes cannot be reverted because the change state doesn't
+allow reverting the change, the response is "`409 Conflict`" and
+the error message is contained in the response body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  change is new
+----
+
 [[submit-change]]
 === Submit Change
 --
@@ -6931,10 +7015,24 @@
 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.
+Name of the topic for the revert change. If not set, the default for Revert
+endpoint is the topic of the change being reverted, and the default for the
+RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 |=============================
 
+[[revert-submission-info]]
+=== RevertSubmissionInfo
+The `RevertSubmissionInfo` describes the revert changes.
+
+[options="header",cols="1,6"]
+|==============================
+|Field Name       | Description
+|`revert_changes` |
+A list of link:#change-info[ChangeInfo] that describes the revert changes. Each
+entity in that list is a revert change that was created in that revert
+submission.
+|==============================
+
 [[review-info]]
 === ReviewInfo
 The `ReviewInfo` entity contains information about a review.
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index c1349aa..77be4b3 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2925,6 +2925,314 @@
   HTTP/1.1 204 No Content
 ----
 
+[[label-endpoints]]
+== Label Endpoints
+
+[[list-labels]]
+=== List Labels
+--
+'GET /projects/link:#project-name[\{project-name\}]/labels/'
+--
+
+Lists the labels that are defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/labels/ HTTP/1.0
+----
+
+As result a list of link:#label-definition-info[LabelDefinitionInfo] entities
+is returned that describe the labels that are defined in this project
+(inherited labels are not returned unless the `inherited` parameter is set, see
+link:#list-with-inherited-labels[below]). The returned labels are sorted by
+label name.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "project": "All-Projects",
+      "function": "MaxWithBlock",
+      "values": {
+        " 0": "No score",
+        "-1": "I would prefer this is not merged as is",
+        "-2": "This shall not be merged",
+        "+1": "Looks good to me, but someone else must approve",
+        "+2": "Looks good to me, approved"
+      },
+      "default_value": 0,
+      "can_override": true,
+      "copy_min_score": true,
+      "copy_all_scores_if_no_change": true,
+      "copy_all_scores_on_trivial_rebase": true,
+      "allow_post_submit": true
+    }
+  ]
+----
+
+[[list-with-inherited-labels]]
+To include inherited labels from all parent projects the parameter `inherited`
+can be set.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project and all its parent projects.
+
+.Request
+----
+  GET /projects/My-Project/labels/?inherited HTTP/1.0
+----
+
+As result a list of link:#label-definition-info[LabelDefinitionInfo] entities
+is returned that describe the labels that are defined in this project and in
+all its parent projects. The returned labels are sorted by parent projects
+in-order from `All-Projects` through the project hierarchy to this project.
+Labels that belong to the same project are sorted by label name.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "project": "All-Projects",
+      "function": "MaxWithBlock",
+      "values": {
+        " 0": "No score",
+        "-1": "I would prefer this is not merged as is",
+        "-2": "This shall not be merged",
+        "+1": "Looks good to me, but someone else must approve",
+        "+2": "Looks good to me, approved"
+      },
+      "default_value": 0,
+      "can_override": true,
+      "copy_min_score": true,
+      "copy_all_scores_if_no_change": true,
+      "copy_all_scores_on_trivial_rebase": true,
+      "allow_post_submit": true
+    },
+    {
+      "name": "Foo-Review",
+      "project": "My-Project",
+      "function": "MaxWithBlock",
+      "values": {
+        " 0": "No score",
+        "-1": "I would prefer this is not merged as is",
+        "-2": "This shall not be merged",
+        "+1": "Looks good to me, but someone else must approve",
+        "+2": "Looks good to me, approved"
+      },
+      "default_value": 0,
+      "can_override": true,
+      "copy_any_score": true,
+      "allow_post_submit": true
+    }
+  ]
+----
+
+[[get-label]]
+=== Get Label
+--
+'GET /projects/link:#project-name[\{project-name\}]/labels/link:#label-name[\{label-name\}]'
+--
+
+Retrieves the definition of a label that is defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/labels/Code-Review HTTP/1.0
+----
+
+As response a link:#label-definition-info[LabelDefinitionInfo] entity is
+returned that describes the label.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "project": "All-Projects",
+    "function": "MaxWithBlock",
+    "values": {
+      " 0": "No score",
+      "-1": "I would prefer this is not merged as is",
+      "-2": "This shall not be merged",
+      "+1": "Looks good to me, but someone else must approve",
+      "+2": "Looks good to me, approved"
+    },
+    "default_value": 0,
+    "can_override": true,
+    "copy_min_score": true,
+    "copy_all_scores_if_no_change": true,
+    "copy_all_scores_on_trivial_rebase": true,
+    "allow_post_submit": true
+  }
+----
+
+[[create-label]]
+=== Create Label
+--
+'PUT /projects/link:#project-name[\{project-name\}]/labels/link:#label-name[\{label-name\}]'
+--
+
+Creates a new label definition in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+If a label with this name is already defined in this project, this label
+definition is updated (see link:#set-label[Set Label]).
+
+.Request
+----
+  PUT /projects/My-Project/labels/Foo HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Create Foo Label",
+    "values": {
+      " 0": "No score",
+      "-1": "I would prefer this is not merged as is",
+      "-2": "This shall not be merged",
+      "+1": "Looks good to me, but someone else must approve",
+      "+2": "Looks good to me, approved"
+    }
+  }
+----
+
+As response a link:#label-definition-info[LabelDefinitionInfo] entity is
+returned that describes the created label.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Foo",
+    "project_name": "My-Project",
+    "function": "MaxWithBlock",
+    "values": {
+      " 0": "No score",
+      "-1": "I would prefer this is not merged as is",
+      "-2": "This shall not be merged",
+      "+1": "Looks good to me, but someone else must approve",
+      "+2": "Looks good to me, approved"
+    },
+    "default_value": 0,
+    "can_override": true,
+    "copy_all_scores_if_no_change": true,
+    "allow_post_submit": true
+  }
+----
+
+[[set-label]]
+=== Set Label
+--
+'PUT /projects/link:#project-name[\{project-name\}]/labels/link:#label-name[\{label-name\}]'
+--
+
+Updates the definition of a label that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+Properties which are not set in the input entity are not modified.
+
+.Request
+----
+  PUT /projects/All-Projects/labels/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Ignore self approvals for Code-Review label",
+    "ignore_self_approval": true
+  }
+----
+
+As response a link:#label-definition-info[LabelDefinitionInfo] entity is
+returned that describes the updated label.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "project": "All-Projects",
+    "function": "MaxWithBlock",
+    "values": {
+      " 0": "No score",
+      "-1": "I would prefer this is not merged as is",
+      "-2": "This shall not be merged",
+      "+1": "Looks good to me, but someone else must approve",
+      "+2": "Looks good to me, approved"
+    },
+    "default_value": 0,
+    "can_override": true,
+    "copy_min_score": true,
+    "copy_all_scores_if_no_change": true,
+    "copy_all_scores_on_trivial_rebase": true,
+    "allow_post_submit": true,
+    "ignore_self_approval": true
+  }
+----
+
+[[delete-label]]
+=== Delete Label
+--
+'DELETE /projects/link:#project-name[\{project-name\}]/labels/link:#label-name[\{label-name\}]'
+--
+
+Deletes the definition of a label that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The request body does not need to include a link:#delete-label-input[
+DeleteLabelInput] entity if no commit message is specified.
+
+.Request
+----
+  DELETE /projects/My-Project/labels/Foo-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Delete Foo-Review label",
+  }
+----
+
+If a label was deleted the response is "`204 No Content`".
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 
 [[ids]]
 == IDs
@@ -2949,6 +3257,10 @@
 A special dashboard ID is `default` which represents the default
 dashboard of a project.
 
+[[label-name]]
+=== \{label-name\}
+The name of a review label.
+
 [[project-name]]
 === \{project-name\}
 The name of the project.
@@ -3122,6 +3434,25 @@
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
+
+[[commentlink-input]]
+=== CommentLinkInput
+The `CommentLinkInput` entity describes the input for a
+link:config-gerrit.html#commentlink[commentlink].
+
+|==================================================
+[options="header",cols="1,^2,4"]
+|==================================================
+|Field Name |        |Description
+|`match`    |        |A JavaScript regular expression to match
+positions to be replaced with a hyperlink, as documented in
+link:config-gerrit.html#commentlink.name.match[commentlink.name.match].
+|`link`     |        |The URL to direct the user to whenever the
+regular expression is matched, as documented in
+link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`enabled`  |optional|Whether the commentlink is enabled, as documented
+in link:config-gerrit.html#commentlink.name.enabled[
+commentlink.name.enabled]. If not set the commentlink is enabled.
 |==================================================
 
 [[config-info]]
@@ -3272,6 +3603,11 @@
 Whether empty commits should be rejected when a change is merged.
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
+|commentlinks                              |optional|
+Map of commentlink names to link:#commentlink-input[CommentLinkInput]
+entities to add or update on the project. If the given commentlink
+already exists, it will be updated with the given values, otherwise
+it will be created. If the value is null, that entry is deleted.
 |======================================================
 
 [[config-parameter-info]]
@@ -3377,6 +3713,19 @@
 Tokens such as `${project}` are not resolved.
 |===========================
 
+[[delete-label-input]]
+=== DeleteLabelInput
+The `DeleteLabelInput` entity contains information for deleting a label
+definition in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the deletion of the label in the
+`project.config` file to the `refs/meta/config` branch.
+|=============================
+
 [[delete-branches-input]]
 === DeleteBranchesInput
 The `DeleteBranchesInput` entity contains information about branches that should
@@ -3459,6 +3808,123 @@
 Not set if there is no parent.
 |================================
 
+[[label-definition-info]]
+=== LabelDefinitionInfo
+The `LabelTypeInfo` entity describes a link:config-labels.html[
+review label].
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`name`          ||
+The link:config-labels.html#label_name[name] of the label.
+|`project_name`  ||
+The name of the project in which this label is defined.
+|`function`      ||
+The link:config-labels.html#label_function[function] of the label (can be
+`MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
+|`values`        ||
+The link:config-labels.html#label_value[values] of the label as a map of label
+value to value description. The label values are formatted strings, e.g. "+1"
+instead of "1", " 0" instead of "0".
+|`default_value` ||
+The link:config-labels.html#label_defaultValue[default value] of the label (as
+integer).
+|`branches`      |optional|
+A list of link:config-labels.html#label_branch[branches] for which the label
+applies. A branch can be a ref, a ref pattern or a regular expression. If not
+set, the label applies for all branches.
+|`can_override`  |`false` if not set|
+Whether this label can be link:config-labels.html#label_canOverride[overridden]
+by child projects.
+|`copy_any_score`|`false` if not set|
+Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
+label.
+|`copy_min_score`|`false` if not set|
+Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
+label.
+|`copy_max_score`|`false` if not set|
+Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
+label.
+|`copy_all_scores_if_no_change`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresIfNoChange[
+copyAllScoresIfNoChange] is set on the label.
+|`copy_all_scores_if_no_code_change`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
+copyAllScoresIfNoCodeChange] is set on the label.
+|`copy_all_scores_on_trivial_rebase`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
+copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
+copyAllScoresOnMergeFirstParentUpdate] is set on the label.
+|`allow_post_submit`|`false` if not set|
+Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
+on the label.
+|`ignore_self_approval`|`false` if not set|
+Whether link:config-labels.html#label_ignoreSelfApproval[ignoreSelfApproval] is
+set on the label.
+|=============================
+
+[[label-definition-input]]
+=== LabelDefinitionInput
+The `LabelTypeInput` entity describes a link:config-labels.html[
+review label].
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the change of the label in the
+`project.config` file to the `refs/meta/config` branch.
+|`name`          |optional|
+The new link:config-labels.html#label_name[name] of the label.
+|`function`      |optional|
+The new link:config-labels.html#label_function[function] of the label (can be
+`MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
+|`values`        |optional|
+The new link:config-labels.html#label_value[values] of the label as a map of
+label value to value description. The label values are formatted strings, e.g.
+"+1" instead of "1", " 0" instead of "0".
+|`default_value` |optional|
+The new link:config-labels.html#label_defaultValue[default value] of the label
+(as integer).
+|`branches`      |optional|
+The new branches for which the label applies as a list of
+link:config-labels.html#label_branch[branches]. A branch can be a ref, a ref
+pattern or a regular expression. If not set, the label applies for all
+branches.
+|`can_override`  |optional|
+Whether this label can be link:config-labels.html#label_canOverride[overridden]
+by child projects.
+|`copy_any_score`|optional|
+Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
+label.
+|`copy_min_score`|optional|
+Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
+label.
+|`copy_max_score`|optional|
+Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
+label.
+|`copy_all_scores_if_no_change`|optional|
+Whether link:config-labels.html#label_copyAllScoresIfNoChange[
+copyAllScoresIfNoChange] is set on the label.
+|`copy_all_scores_if_no_code_change`|optional|
+Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
+copyAllScoresIfNoCodeChange] is set on the label.
+|`copy_all_scores_on_trivial_rebase`|optional|
+Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
+copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_on_merge_first_parent_update`|optional|
+Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
+copyAllScoresOnMergeFirstParentUpdate] is set on the label.
+|`allow_post_submit`|optional|
+Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
+on the label.
+|`ignore_self_approval`|optional|
+Whether link:config-labels.html#label_ignoreSelfApproval[ignoreSelfApproval] is
+set on the label.
+|=============================
 
 [[label-type-info]]
 === LabelTypeInfo
diff --git a/WORKSPACE b/WORKSPACE
index 475f822..ac3d5e0 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -776,8 +776,8 @@
 
 maven_jar(
     name = "dropwizard-core",
-    artifact = "io.dropwizard.metrics:metrics-core:4.0.7",
-    sha1 = "673899f605f52ca35836673ccfee97154a496a61",
+    artifact = "io.dropwizard.metrics:metrics-core:4.1.1",
+    sha1 = "ebfafc716d9c3b6151dc7c2c09ce925a163a4f21",
 )
 
 # When updating Bouncy Castle, also update it in bazlets.
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index f9cd562..14b8310 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -157,6 +157,10 @@
     return name;
   }
 
+  public void setName(String name) {
+    this.name = checkName(name);
+  }
+
   public boolean matches(PatchSetApproval psa) {
     return psa.labelId().get().equalsIgnoreCase(name);
   }
@@ -173,6 +177,7 @@
     return canOverride;
   }
 
+  @Nullable
   public List<String> getRefPatterns() {
     return refPatterns;
   }
@@ -198,7 +203,7 @@
   }
 
   public void setRefPatterns(List<String> refPatterns) {
-    if (refPatterns != null) {
+    if (refPatterns != null && !refPatterns.isEmpty()) {
       this.refPatterns =
           refPatterns.stream().collect(collectingAndThen(toList(), Collections::unmodifiableList));
     } else {
@@ -210,6 +215,10 @@
     return values;
   }
 
+  public void setValues(List<LabelValue> values) {
+    this.values = sortValues(values);
+  }
+
   public LabelValue getMin() {
     if (values.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/common/data/PatchScript.java b/java/com/google/gerrit/common/data/PatchScript.java
index c177e35..73e301b 100644
--- a/java/com/google/gerrit/common/data/PatchScript.java
+++ b/java/com/google/gerrit/common/data/PatchScript.java
@@ -37,30 +37,44 @@
     GITLINK
   }
 
+  public static class PatchScriptFileInfo {
+    public final String name;
+    public final FileMode mode;
+    public final SparseFileContent content;
+    public final DisplayMethod displayMethod;
+    public final String mimeType;
+    public final String commitId;
+
+    public PatchScriptFileInfo(
+        String name,
+        FileMode mode,
+        SparseFileContent content,
+        DisplayMethod displayMethod,
+        String mimeType,
+        String commitId) {
+      this.name = name;
+      this.mode = mode;
+      this.content = content;
+      this.displayMethod = displayMethod;
+      this.mimeType = mimeType;
+      this.commitId = commitId;
+    }
+  }
+
   private Change.Key changeId;
   private ChangeType changeType;
-  private String oldName;
-  private String newName;
-  private FileMode oldMode;
-  private FileMode newMode;
   private List<String> header;
   private DiffPreferencesInfo diffPrefs;
-  private SparseFileContent a;
-  private SparseFileContent b;
   private List<Edit> edits;
   private Set<Edit> editsDueToRebase;
-  private DisplayMethod displayMethodA;
-  private DisplayMethod displayMethodB;
-  private transient String mimeTypeA;
-  private transient String mimeTypeB;
   private CommentDetail comments;
   private List<Patch> history;
   private boolean hugeFile;
   private boolean intralineFailure;
   private boolean intralineTimeout;
   private boolean binary;
-  private transient String commitIdA;
-  private transient String commitIdB;
+  private PatchScriptFileInfo fileInfoA;
+  private PatchScriptFileInfo fileInfoB;
 
   public PatchScript(
       Change.Key ck,
@@ -89,50 +103,39 @@
       String cmb) {
     changeId = ck;
     changeType = ct;
-    oldName = on;
-    newName = nn;
-    oldMode = om;
-    newMode = nm;
     header = h;
     diffPrefs = dp;
-    a = ca;
-    b = cb;
     edits = e;
     this.editsDueToRebase = editsDueToRebase;
-    displayMethodA = ma;
-    displayMethodB = mb;
-    mimeTypeA = mta;
-    mimeTypeB = mtb;
     comments = cd;
     history = hist;
     hugeFile = hf;
     intralineFailure = idf;
     intralineTimeout = idt;
     binary = bin;
-    commitIdA = cma;
-    commitIdB = cmb;
-  }
 
-  protected PatchScript() {}
+    fileInfoA = new PatchScriptFileInfo(on, om, ca, ma, mta, cma);
+    fileInfoB = new PatchScriptFileInfo(nn, nm, cb, mb, mtb, cmb);
+  }
 
   public Change.Key getChangeId() {
     return changeId;
   }
 
   public DisplayMethod getDisplayMethodA() {
-    return displayMethodA;
+    return fileInfoA.displayMethod;
   }
 
   public DisplayMethod getDisplayMethodB() {
-    return displayMethodB;
+    return fileInfoB.displayMethod;
   }
 
   public FileMode getFileModeA() {
-    return oldMode;
+    return fileInfoA.mode;
   }
 
   public FileMode getFileModeB() {
-    return newMode;
+    return fileInfoB.mode;
   }
 
   public List<String> getPatchHeader() {
@@ -144,11 +147,11 @@
   }
 
   public String getOldName() {
-    return oldName;
+    return fileInfoA.name;
   }
 
   public String getNewName() {
-    return newName;
+    return fileInfoB.name;
   }
 
   public CommentDetail getCommentDetail() {
@@ -188,19 +191,19 @@
   }
 
   public SparseFileContent getA() {
-    return a;
+    return fileInfoA.content;
   }
 
   public SparseFileContent getB() {
-    return b;
+    return fileInfoB.content;
   }
 
   public String getMimeTypeA() {
-    return mimeTypeA;
+    return fileInfoA.mimeType;
   }
 
   public String getMimeTypeB() {
-    return mimeTypeB;
+    return fileInfoB.mimeType;
   }
 
   public List<Edit> getEdits() {
@@ -216,10 +219,18 @@
   }
 
   public String getCommitIdA() {
-    return commitIdA;
+    return fileInfoA.commitId;
   }
 
   public String getCommitIdB() {
-    return commitIdB;
+    return fileInfoB.commitId;
+  }
+
+  public PatchScriptFileInfo getFileInfoA() {
+    return fileInfoA;
+  }
+
+  public PatchScriptFileInfo getFileInfoB() {
+    return fileInfoB;
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 26a1a27..119d941 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -161,6 +162,12 @@
    */
   ChangeApi revert(RevertInput in) throws RestApiException;
 
+  default RevertSubmissionInfo revertSubmission() throws RestApiException {
+    return revertSubmission(new RevertInput());
+  }
+
+  RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException;
+
   /** Create a merge patch set for the change. */
   ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
@@ -502,6 +509,11 @@
     }
 
     @Override
+    public RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void rebase(RebaseInput in) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
new file mode 100644
index 0000000..3aad7e1
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
@@ -0,0 +1,27 @@
+// 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.extensions.api.projects;
+
+/*
+ * Input for a commentlink configuration on a project.
+ */
+public class CommentLinkInput {
+  /** A JavaScript regular expression to match positions to be replaced with a hyperlink. */
+  public String match;
+  /** The URL to direct the user to whenever the regular expression is matched. */
+  public String link;
+  /** Whether the commentlink is enabled. */
+  public Boolean enabled;
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 1a6d77b..8005fc5 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -38,4 +38,5 @@
   public SubmitType submitType;
   public ProjectState state;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
+  public Map<String, CommentLinkInput> commentLinks;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/LabelApi.java b/java/com/google/gerrit/extensions/api/projects/LabelApi.java
new file mode 100644
index 0000000..975a57e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/LabelApi.java
@@ -0,0 +1,61 @@
+// 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.api.projects;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface LabelApi {
+  LabelApi create(LabelDefinitionInput input) throws RestApiException;
+
+  LabelDefinitionInfo get() throws RestApiException;
+
+  LabelDefinitionInfo update(LabelDefinitionInput input) throws RestApiException;
+
+  default void delete() throws RestApiException {
+    delete(null);
+  }
+
+  void delete(@Nullable String commitMessage) throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements LabelApi {
+    @Override
+    public LabelApi create(LabelDefinitionInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public LabelDefinitionInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public LabelDefinitionInfo update(LabelDefinitionInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete(@Nullable String commitMessage) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ParentInput.java b/java/com/google/gerrit/extensions/api/projects/ParentInput.java
index 6e481ae..d68bb3b 100644
--- a/java/com/google/gerrit/extensions/api/projects/ParentInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ParentInput.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.common.InputWithCommitMessage;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
-public class ParentInput {
+public class ParentInput extends InputWithCommitMessage {
   @DefaultInput public String parent;
-  public String commitMessage;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index c6d9dee..6d02ec4 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -204,6 +205,21 @@
   /** Reindexes all changes of the project. */
   void indexChanges() throws RestApiException;
 
+  ListLabelsRequest labels() throws RestApiException;
+
+  abstract class ListLabelsRequest {
+    protected boolean inherited;
+
+    public abstract List<LabelDefinitionInfo> get() throws RestApiException;
+
+    public ListLabelsRequest withInherited(boolean inherited) {
+      this.inherited = inherited;
+      return this;
+    }
+  }
+
+  LabelApi label(String labelName) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -378,5 +394,15 @@
     public void indexChanges() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ListLabelsRequest labels() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public LabelApi label(String labelName) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java b/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
index 0083c0e..3662b7f 100644
--- a/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.common.InputWithCommitMessage;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
-public class SetDashboardInput {
+public class SetDashboardInput extends InputWithCommitMessage {
   @DefaultInput public String id;
-  public String commitMessage;
 }
diff --git a/java/com/google/gerrit/extensions/common/InputWithCommitMessage.java b/java/com/google/gerrit/extensions/common/InputWithCommitMessage.java
new file mode 100644
index 0000000..34bc203
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/InputWithCommitMessage.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.extensions.common;
+
+import com.google.gerrit.common.Nullable;
+
+/** A generic input with a commit message only. */
+public class InputWithCommitMessage {
+  @Nullable public String commitMessage;
+
+  public InputWithCommitMessage() {
+    this(null);
+  }
+
+  public InputWithCommitMessage(@Nullable String commitMessage) {
+    this.commitMessage = commitMessage;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/InputWithMessage.java b/java/com/google/gerrit/extensions/common/InputWithMessage.java
new file mode 100644
index 0000000..45d23cf
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/InputWithMessage.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.common;
+
+import com.google.gerrit.common.Nullable;
+
+/**
+ * A generic input with a message only.
+ *
+ * <p>See also {@link InputWithCommitMessage}.
+ */
+public class InputWithMessage {
+  @Nullable public String message;
+
+  public InputWithMessage() {
+    this(null);
+  }
+
+  public InputWithMessage(@Nullable String message) {
+    this.message = message;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
new file mode 100644
index 0000000..64c3997
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.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.extensions.common;
+
+import java.util.List;
+import java.util.Map;
+
+public class LabelDefinitionInfo {
+  public String name;
+  public String projectName;
+  public String function;
+  public Map<String, String> values;
+  public short defaultValue;
+  public List<String> branches;
+  public Boolean canOverride;
+  public Boolean copyAnyScore;
+  public Boolean copyMinScore;
+  public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfNoChange;
+  public Boolean copyAllScoresIfNoCodeChange;
+  public Boolean copyAllScoresOnTrivialRebase;
+  public Boolean copyAllScoresOnMergeFirstParentUpdate;
+  public Boolean allowPostSubmit;
+  public Boolean ignoreSelfApproval;
+}
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
new file mode 100644
index 0000000..0523f61
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.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.extensions.common;
+
+import java.util.List;
+import java.util.Map;
+
+public class LabelDefinitionInput extends InputWithCommitMessage {
+  public String name;
+  public String function;
+  public Map<String, String> values;
+  public Short defaultValue;
+  public List<String> branches;
+  public Boolean canOverride;
+  public Boolean copyAnyScore;
+  public Boolean copyMinScore;
+  public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfNoChange;
+  public Boolean copyAllScoresIfNoCodeChange;
+  public Boolean copyAllScoresOnTrivialRebase;
+  public Boolean copyAllScoresOnMergeFirstParentUpdate;
+  public Boolean allowPostSubmit;
+  public Boolean ignoreSelfApproval;
+}
diff --git a/java/com/google/gerrit/extensions/common/RevertSubmissionInfo.java b/java/com/google/gerrit/extensions/common/RevertSubmissionInfo.java
new file mode 100644
index 0000000..dabd035
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/RevertSubmissionInfo.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+public class RevertSubmissionInfo {
+  public List<ChangeInfo> revertChanges;
+}
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index 5504cfd..bec7fc3 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -30,6 +30,12 @@
     return new Impl<>(200, value);
   }
 
+  /** HTTP 200 OK: with empty value. */
+  public static Response<String> ok() {
+    return ok("");
+  }
+
+  /** HTTP 200 OK: with forced revalidation of cache. */
   public static <T> Response<T> withMustRevalidate(T value) {
     return ok(value).caching(CacheControl.PRIVATE(0, TimeUnit.SECONDS).setMustRevalidate());
   }
@@ -39,6 +45,11 @@
     return new Impl<>(201, value);
   }
 
+  /** HTTP 201 Created: with empty value. */
+  public static Response<String> created() {
+    return created("");
+  }
+
   /** HTTP 202 Accepted: accepted as background task. */
   public static Accepted accepted(String location) {
     return new Accepted(location);
diff --git a/java/com/google/gerrit/index/query/QueryBuilder.java b/java/com/google/gerrit/index/query/QueryBuilder.java
index d24cfeb..85dcf3e 100644
--- a/java/com/google/gerrit/index/query/QueryBuilder.java
+++ b/java/com/google/gerrit/index/query/QueryBuilder.java
@@ -29,6 +29,7 @@
 
 import com.google.common.base.Ascii;
 import com.google.common.base.CharMatcher;
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
@@ -42,7 +43,9 @@
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import org.antlr.runtime.tree.Tree;
 
 /**
@@ -184,6 +187,7 @@
 
   protected final Definition<T, Q> builderDef;
   private final ImmutableMap<String, OperatorFactory<T, Q>> opFactories;
+  protected Map<String, String> opAliases = Collections.emptyMap();
 
   protected QueryBuilder(
       Definition<T, Q> def,
@@ -220,6 +224,10 @@
     return toPredicate(QueryParser.parse(query));
   }
 
+  public void setOperatorAliases(Map<String, String> opAliases) {
+    this.opAliases = opAliases;
+  }
+
   /**
    * Parse multiple user-supplied query strings into a list of predicates.
    *
@@ -290,8 +298,12 @@
 
   @SuppressWarnings("unchecked")
   private Predicate<T> operator(String name, String value) throws QueryParseException {
+    String opName = MoreObjects.firstNonNull(opAliases.get(name), name);
     @SuppressWarnings("rawtypes")
-    OperatorFactory f = opFactories.get(name);
+    OperatorFactory f = opFactories.get(opName);
+    if (f == null && !opName.equals(name)) {
+      f = opFactories.get(name);
+    }
     if (f == null) {
       throw error("Unsupported operator " + name + ":" + value);
     }
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 22bc21d..096e4a1 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -36,8 +36,10 @@
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import javax.servlet.DispatcherType;
@@ -411,10 +413,20 @@
         Class<? extends Filter> filterClass =
             (Class<? extends Filter>) Class.forName(filterClassName);
         Filter filter = env.webInjector.getInstance(filterClass);
-        app.addFilter(
-            new FilterHolder(filter),
-            "/*",
-            EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
+
+        Map<String, String> initParams = new HashMap<>();
+        Set<String> initParamKeys = cfg.getNames("filterClass", filterClassName, true);
+        initParamKeys.forEach(
+            paramKey -> {
+              String paramValue = cfg.getString("filterClass", filterClassName, paramKey);
+              initParams.put(paramKey, paramValue);
+            });
+
+        FilterHolder filterHolder = new FilterHolder(filter);
+        if (initParams.size() > 0) {
+          filterHolder.setInitParameters(initParams);
+        }
+        app.addFilter(filterHolder, "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
       } catch (Throwable e) {
         throw new IllegalArgumentException(
             "Unable to instantiate front-end HTTP Filter " + filterClassName, e);
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 6675595..dbaf9c3 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -54,6 +54,7 @@
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/git",
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index ee82a26..a166d97 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -25,7 +25,6 @@
 import java.io.IOException;
 import java.security.SecureRandom;
 import java.util.Collection;
-import java.util.Map;
 import java.util.Random;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -50,22 +49,6 @@
   }
 
   /**
-   * Get the next patch set ID from a previously-read map of all refs.
-   *
-   * @param allRefs map of full ref name to ref.
-   * @param id previous patch set ID.
-   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
-   *     names appear in the {@code allRefs} map.
-   */
-  public static PatchSet.Id nextPatchSetIdFromAllRefsMap(Map<String, Ref> allRefs, PatchSet.Id id) {
-    PatchSet.Id next = nextPatchSetId(id);
-    while (allRefs.containsKey(next.toRefName())) {
-      next = nextPatchSetId(next);
-    }
-    return next;
-  }
-
-  /**
    * Get the next patch set ID from a previously-read map of refs below the change prefix.
    *
    * @param changeRefNames existing full change ref names with the same change ID as {@code id}.
@@ -95,9 +78,7 @@
   /**
    * Get the next patch set ID just looking at a single previous patch set ID.
    *
-   * <p>This patch set ID may or may not be available in the database; callers that want a
-   * previously-unused ID should use {@link #nextPatchSetIdFromAllRefsMap} or {@link
-   * #nextPatchSetIdFromChangeRefs}.
+   * <p>This patch set ID may or may not be available in the database.
    *
    * @param id previous patch set ID.
    * @return next patch set ID for the same change, incrementing by 1.
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 26539c5..c446c92 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -15,12 +15,13 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.entities.Comment.Status;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Comment.Status;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
@@ -34,10 +35,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 
 @Singleton
 public class PublishCommentUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final CommentsUtil commentsUtil;
@@ -63,11 +68,32 @@
 
     Map<PatchSet.Id, PatchSet> patchSets =
         psUtil.getAsMap(notes, draftComments.stream().map(d -> psId(notes, d)).collect(toSet()));
+    Set<Comment> commentsToPublish = new HashSet<>();
     for (Comment draftComment : draftComments) {
       PatchSet.Id psIdOfDraftComment = psId(notes, draftComment);
       PatchSet ps = patchSets.get(psIdOfDraftComment);
       if (ps == null) {
-        throw new StorageException("patch set " + psIdOfDraftComment + " not found");
+        // This can happen if changes with the same numeric ID exist:
+        // - change 12345 has 3 patch sets in repo X
+        // - another change 12345 has 7 patch sets in repo Y
+        // - the user saves a draft comment on patch set 6 of the change in repo Y
+        // - this draft comment gets stored in:
+        //   AllUsers -> refs/draft-comments/45/12345/<account-id>
+        // - when posting a review with draft handling PUBLISH_ALL_REVISIONS on the change in
+        //   repo X, the draft comments are loaded from
+        //   AllUsers -> refs/draft-comments/45/12345/<account-id>, including the draft
+        //   comment that was saved for patch set 6 of the change in repo Y
+        // - patch set 6 does not exist for the change in repo x, hence we get null for the patch
+        //   set here
+        // Instead of failing hard (and returning an Internal Server Error) to the caller,
+        // just ignore that comment.
+        // Gerrit ensures that numeric change IDs are unique, but you can get duplicates if
+        // change refs of one repo are copied/pushed to another repo on the same host (this
+        // should never be done, but we know it happens).
+        logger.atWarning().log(
+            "Ignoring draft comment %s on non existing patch set %s (repo = %s)",
+            draftComment, psIdOfDraftComment, notes.getProjectName());
+        continue;
       }
       draftComment.writtenOn = ctx.getWhen();
       draftComment.tag = tag;
@@ -79,8 +105,9 @@
       } catch (PatchListNotAvailableException e) {
         throw new StorageException(e);
       }
+      commentsToPublish.add(draftComment);
     }
-    commentsUtil.putComments(ctx.getUpdate(psId), Status.PUBLISHED, draftComments);
+    commentsUtil.putComments(ctx.getUpdate(psId), Status.PUBLISHED, commentsToPublish);
   }
 
   private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index fb3d4ea..a6c5d5c 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -106,6 +106,6 @@
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
-    return alreadyActive.get() ? Response.ok("") : Response.created("");
+    return alreadyActive.get() ? Response.ok() : Response.created();
   }
 }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a04be30..0d640d9 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -48,8 +48,10 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -62,7 +64,6 @@
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
@@ -96,6 +97,7 @@
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Restore;
 import com.google.gerrit.server.restapi.change.Revert;
+import com.google.gerrit.server.restapi.change.RevertSubmission;
 import com.google.gerrit.server.restapi.change.Reviewers;
 import com.google.gerrit.server.restapi.change.Revisions;
 import com.google.gerrit.server.restapi.change.SetReadyForReview;
@@ -132,6 +134,7 @@
   private final ChangeResource change;
   private final Abandon abandon;
   private final Revert revert;
+  private final RevertSubmission revertSubmission;
   private final Restore restore;
   private final CreateMergePatchSet updateByMerge;
   private final Provider<SubmittedTogether> submittedTogether;
@@ -181,6 +184,7 @@
       ListReviewers listReviewers,
       Abandon abandon,
       Revert revert,
+      RevertSubmission revertSubmission,
       Restore restore,
       CreateMergePatchSet updateByMerge,
       Provider<SubmittedTogether> submittedTogether,
@@ -219,6 +223,7 @@
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
+    this.revertSubmission = revertSubmission;
     this.reviewers = reviewers;
     this.revisions = revisions;
     this.reviewerApi = reviewerApi;
@@ -319,7 +324,7 @@
   @Override
   public void setPrivate(boolean value, @Nullable String message) throws RestApiException {
     try {
-      SetPrivateOp.Input input = new SetPrivateOp.Input(message);
+      InputWithMessage input = new InputWithMessage(message);
       if (value) {
         postPrivate.apply(change, input);
       } else {
@@ -358,6 +363,15 @@
   }
 
   @Override
+  public RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException {
+    try {
+      return revertSubmission.apply(change, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot revert a change submission", e);
+    }
+  }
+
+  @Override
   public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
     try {
       return updateByMerge.apply(change, in).value();
diff --git a/java/com/google/gerrit/server/api/projects/LabelApiImpl.java b/java/com/google/gerrit/server/api/projects/LabelApiImpl.java
new file mode 100644
index 0000000..ad7ec31
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/LabelApiImpl.java
@@ -0,0 +1,118 @@
+// 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.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.LabelApi;
+import com.google.gerrit.extensions.common.InputWithCommitMessage;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.CreateLabel;
+import com.google.gerrit.server.restapi.project.DeleteLabel;
+import com.google.gerrit.server.restapi.project.GetLabel;
+import com.google.gerrit.server.restapi.project.LabelsCollection;
+import com.google.gerrit.server.restapi.project.SetLabel;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class LabelApiImpl implements LabelApi {
+  interface Factory {
+    LabelApiImpl create(ProjectResource project, String label);
+  }
+
+  private final LabelsCollection labels;
+  private final CreateLabel createLabel;
+  private final GetLabel getLabel;
+  private final SetLabel setLabel;
+  private final DeleteLabel deleteLabel;
+  private final ProjectCache projectCache;
+  private final String label;
+
+  private ProjectResource project;
+
+  @Inject
+  LabelApiImpl(
+      LabelsCollection labels,
+      CreateLabel createLabel,
+      GetLabel getLabel,
+      SetLabel setLabel,
+      DeleteLabel deleteLabel,
+      ProjectCache projectCache,
+      @Assisted ProjectResource project,
+      @Assisted String label) {
+    this.labels = labels;
+    this.createLabel = createLabel;
+    this.getLabel = getLabel;
+    this.setLabel = setLabel;
+    this.deleteLabel = deleteLabel;
+    this.projectCache = projectCache;
+    this.project = project;
+    this.label = label;
+  }
+
+  @Override
+  public LabelApi create(LabelDefinitionInput input) throws RestApiException {
+    try {
+      createLabel.apply(project, IdString.fromDecoded(label), input);
+
+      // recreate project resource because project state was updated by creating the new label and
+      // needs to be reloaded
+      project =
+          new ProjectResource(projectCache.checkedGet(project.getNameKey()), project.getUser());
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create branch", e);
+    }
+  }
+
+  @Override
+  public LabelDefinitionInfo get() throws RestApiException {
+    try {
+      return getLabel.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get label", e);
+    }
+  }
+
+  @Override
+  public LabelDefinitionInfo update(LabelDefinitionInput input) throws RestApiException {
+    try {
+      return setLabel.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update label", e);
+    }
+  }
+
+  @Override
+  public void delete(@Nullable String commitMessage) throws RestApiException {
+    try {
+      deleteLabel.apply(resource(), new InputWithCommitMessage(commitMessage));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete label", e);
+    }
+  }
+
+  private LabelResource resource() throws RestApiException, PermissionBackendException {
+    return labels.parse(project, IdString.fromDecoded(label));
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/Module.java b/java/com/google/gerrit/server/api/projects/Module.java
index f1e21d28..8df5495 100644
--- a/java/com/google/gerrit/server/api/projects/Module.java
+++ b/java/com/google/gerrit/server/api/projects/Module.java
@@ -28,5 +28,6 @@
     factory(ChildProjectApiImpl.Factory.class);
     factory(CommitApiImpl.Factory.class);
     factory(DashboardApiImpl.Factory.class);
+    factory(LabelApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 1ac905d..d7ab91b 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.HeadInput;
 import com.google.gerrit.extensions.api.projects.IndexProjectInput;
+import com.google.gerrit.extensions.api.projects.LabelApi;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -44,6 +45,7 @@
 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.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -73,6 +75,7 @@
 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.ListLabels;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.PutConfig;
@@ -127,6 +130,8 @@
   private final SetParent setParent;
   private final Index index;
   private final IndexChanges indexChanges;
+  private final Provider<ListLabels> listLabels;
+  private final LabelApiImpl.Factory labelApi;
 
   @AssistedInject
   ProjectApiImpl(
@@ -162,6 +167,8 @@
       SetParent setParent,
       Index index,
       IndexChanges indexChanges,
+      Provider<ListLabels> listLabels,
+      LabelApiImpl.Factory labelApi,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -197,6 +204,8 @@
         setParent,
         index,
         indexChanges,
+        listLabels,
+        labelApi,
         null);
   }
 
@@ -234,6 +243,8 @@
       SetParent setParent,
       Index index,
       IndexChanges indexChanges,
+      Provider<ListLabels> listLabels,
+      LabelApiImpl.Factory labelApi,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -269,6 +280,8 @@
         setParent,
         index,
         indexChanges,
+        listLabels,
+        labelApi,
         name);
   }
 
@@ -306,6 +319,8 @@
       SetParent setParent,
       Index index,
       IndexChanges indexChanges,
+      Provider<ListLabels> listLabels,
+      LabelApiImpl.Factory labelApi,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -341,6 +356,8 @@
     this.name = name;
     this.index = index;
     this.indexChanges = indexChanges;
+    this.listLabels = listLabels;
+    this.labelApi = labelApi;
   }
 
   @Override
@@ -672,4 +689,27 @@
     }
     return project;
   }
+
+  @Override
+  public ListLabelsRequest labels() {
+    return new ListLabelsRequest() {
+      @Override
+      public List<LabelDefinitionInfo> get() throws RestApiException {
+        try {
+          return listLabels.get().withInherited(inherited).apply(checkExists()).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list labels", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public LabelApi label(String labelName) throws RestApiException {
+    try {
+      return labelApi.create(checkExists(), labelName);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse label", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
index 5c7946c..49c1fe2 100644
--- a/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -100,7 +100,7 @@
 
   public BinaryResult getContent(
       Repository repo, ProjectState project, ObjectId revstr, String path)
-      throws IOException, ResourceNotFoundException {
+      throws IOException, ResourceNotFoundException, BadRequestException {
     try (RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(revstr);
       try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) {
@@ -114,6 +114,10 @@
           return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64();
         }
 
+        if (mode == org.eclipse.jgit.lib.FileMode.TREE) {
+          throw new BadRequestException("cannot retrieve content of directories");
+        }
+
         ObjectLoader obj = repo.open(id, OBJ_BLOB);
         byte[] raw;
         try {
diff --git a/java/com/google/gerrit/server/change/SetPrivateOp.java b/java/com/google/gerrit/server/change/SetPrivateOp.java
index 28d178d..382a4f6 100644
--- a/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -34,25 +35,15 @@
 import com.google.inject.assistedinject.Assisted;
 
 public class SetPrivateOp implements BatchUpdateOp {
-  public static class Input {
-    String message;
-
-    public Input() {}
-
-    public Input(String message) {
-      this.message = message;
-    }
-  }
-
   public interface Factory {
-    SetPrivateOp create(boolean isPrivate, @Nullable Input input);
+    SetPrivateOp create(boolean isPrivate, @Nullable InputWithMessage input);
   }
 
   private final PrivateStateChanged privateStateChanged;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
   private final boolean isPrivate;
-  @Nullable private final Input input;
+  @Nullable private final InputWithMessage input;
 
   private Change change;
   private PatchSet ps;
@@ -64,7 +55,7 @@
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
       @Assisted boolean isPrivate,
-      @Assisted @Nullable Input input) {
+      @Assisted @Nullable InputWithMessage input) {
     this.privateStateChanged = privateStateChanged;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 78edadab..283cff8 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
@@ -34,15 +35,15 @@
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
-  public static class Input {
-    @Nullable public String message;
-
+  public static class Input extends InputWithMessage {
     @Nullable public NotifyHandling notify;
 
-    public Input() {}
+    public Input() {
+      this(null);
+    }
 
-    public Input(String message) {
-      this.message = message;
+    public Input(@Nullable String message) {
+      super(message);
     }
   }
 
diff --git a/java/com/google/gerrit/server/config/OperatorAliasConfig.java b/java/com/google/gerrit/server/config/OperatorAliasConfig.java
new file mode 100644
index 0000000..0c5fc6e
--- /dev/null
+++ b/java/com/google/gerrit/server/config/OperatorAliasConfig.java
@@ -0,0 +1,46 @@
+// 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 com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class OperatorAliasConfig {
+  private static final String SECTION = "operator-alias";
+  private static final String SUBSECTION_CHANGE = "change";
+  private final Config cfg;
+  private final Map<String, String> changeQueryOperatorAliases;
+
+  @Inject
+  OperatorAliasConfig(@GerritServerConfig Config cfg) {
+    this.cfg = cfg;
+    changeQueryOperatorAliases = new HashMap<>();
+    loadChangeOperatorAliases();
+  }
+
+  public Map<String, String> getChangeQueryOperatorAliases() {
+    return changeQueryOperatorAliases;
+  }
+
+  private void loadChangeOperatorAliases() {
+    for (String name : cfg.getNames(SECTION, SUBSECTION_CHANGE)) {
+      changeQueryOperatorAliases.put(name, cfg.getString(SECTION, SUBSECTION_CHANGE, name));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index afaf695..c05a47d 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -17,12 +17,14 @@
 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.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.git.LockFailureException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -228,7 +230,12 @@
         createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(
+          notes.getProjectName(),
+          repository,
+          optionalChangeEdit.get(),
+          newEditCommit,
+          nowTimestamp);
     } else {
       createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
     }
@@ -331,7 +338,12 @@
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(
+          notes.getProjectName(),
+          repository,
+          optionalChangeEdit.get(),
+          newEditCommit,
+          nowTimestamp);
     } else {
       createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
     }
@@ -384,7 +396,12 @@
         createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      return updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      return updateEdit(
+          notes.getProjectName(),
+          repository,
+          optionalChangeEdit.get(),
+          newEditCommit,
+          nowTimestamp);
     }
     return createEdit(repository, notes, patchSet, newEditCommit, nowTimestamp);
   }
@@ -531,7 +548,13 @@
       throws IOException {
     Change change = notes.getChange();
     String editRefName = getEditRefName(change, basePatchSet);
-    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommitId, timestamp);
+    updateReference(
+        notes.getProjectName(),
+        repository,
+        editRefName,
+        ObjectId.zeroId(),
+        newEditCommitId,
+        timestamp);
     reindex(change);
 
     RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
@@ -544,11 +567,16 @@
   }
 
   private ChangeEdit updateEdit(
-      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommitId, Timestamp timestamp)
+      Project.NameKey projectName,
+      Repository repository,
+      ChangeEdit changeEdit,
+      ObjectId newEditCommitId,
+      Timestamp timestamp)
       throws IOException {
     String editRefName = changeEdit.getRefName();
     RevCommit currentEditCommit = changeEdit.getEditCommit();
-    updateReference(repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
+    updateReference(
+        projectName, repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
     reindex(changeEdit.getChange());
 
     RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
@@ -557,6 +585,7 @@
   }
 
   private void updateReference(
+      Project.NameKey projectName,
       Repository repository,
       String refName,
       ObjectId currentObjectId,
@@ -571,14 +600,12 @@
     ru.setForceUpdate(true);
     try (RevWalk revWalk = new RevWalk(repository)) {
       RefUpdate.Result res = ru.update(revWalk);
+      String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
+      if (res == RefUpdate.Result.LOCK_FAILURE) {
+        throw new LockFailureException(message, ru);
+      }
       if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
-        throw new IOException(
-            "cannot update "
-                + ru.getName()
-                + " in "
-                + repository.getDirectory()
-                + ": "
-                + ru.getResult());
+        throw new IOException(message);
       }
     }
   }
diff --git a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
index a1682fe..9d6df7d 100644
--- a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
+++ b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
+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.extensions.restapi.ResourceNotFoundException;
@@ -69,7 +70,8 @@
       ProjectState projectState,
       ObjectId patchSetCommitId,
       List<FixReplacement> fixReplacements)
-      throws ResourceNotFoundException, IOException, ResourceConflictException {
+      throws BadRequestException, ResourceNotFoundException, IOException,
+          ResourceConflictException {
     requireNonNull(fixReplacements, "Fix replacements must not be null");
 
     Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
@@ -91,7 +93,8 @@
       ObjectId patchSetCommitId,
       String filePath,
       List<FixReplacement> fixReplacements)
-      throws ResourceNotFoundException, IOException, ResourceConflictException {
+      throws BadRequestException, ResourceNotFoundException, IOException,
+          ResourceConflictException {
     String fileContent = getFileContent(repository, projectState, patchSetCommitId, filePath);
     String newFileContent = getNewFileContent(fileContent, fixReplacements);
     return new ChangeFileContentModification(filePath, RawInputUtil.create(newFileContent));
@@ -99,7 +102,7 @@
 
   private String getFileContent(
       Repository repository, ProjectState projectState, ObjectId patchSetCommitId, String filePath)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, BadRequestException, IOException {
     try (BinaryResult fileContent =
         fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath)) {
       return fileContent.asString();
diff --git a/java/com/google/gerrit/server/git/DelegateRefDatabase.java b/java/com/google/gerrit/server/git/DelegateRefDatabase.java
index 34dd6a9..decae05 100644
--- a/java/com/google/gerrit/server/git/DelegateRefDatabase.java
+++ b/java/com/google/gerrit/server/git/DelegateRefDatabase.java
@@ -17,6 +17,9 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.RefRename;
@@ -24,7 +27,8 @@
 import org.eclipse.jgit.lib.Repository;
 
 /**
- * Wrapper around {@link RefDatabase} that delegates all calls to the wrapped {@link RefDatabase}.
+ * Wrapper around {@link RefDatabase} that delegates all calls to the wrapped {@link Repository}'s
+ * {@link RefDatabase}.
  */
 public class DelegateRefDatabase extends RefDatabase {
 
@@ -41,7 +45,7 @@
 
   @Override
   public void close() {
-    delegate.close();
+    delegate.getRefDatabase().close();
   }
 
   @Override
@@ -71,6 +75,12 @@
   }
 
   @Override
+  @NonNull
+  public Set<Ref> getTipsWithSha1(ObjectId id) throws IOException {
+    return delegate.getRefDatabase().getTipsWithSha1(id);
+  }
+
+  @Override
   public List<Ref> getAdditionalRefs() throws IOException {
     return delegate.getRefDatabase().getAdditionalRefs();
   }
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index 800490d..b61488b 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -81,7 +81,8 @@
   @SuppressWarnings("rawtypes")
   private static BaseRepositoryBuilder toBuilder(Repository repo) {
     if (!repo.isBare()) {
-      throw new IllegalArgumentException("non-bare repository is not supported");
+      throw new IllegalArgumentException(
+          "non-bare repository is not supported: " + repo.getIdentifier());
     }
 
     return new BaseRepositoryBuilder<>().setFS(repo.getFS()).setGitDir(repo.getDirectory());
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index c284f7f4..1f0dcd4 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -23,16 +23,18 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Multimaps;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 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.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.receive.ReceivePackRefCache;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.Collection;
 import java.util.Deque;
@@ -87,24 +89,29 @@
     return rsrc.getPatchSet().groups();
   }
 
-  private interface Lookup {
+  interface Lookup {
     List<String> lookup(PatchSet.Id psId);
   }
 
-  private final ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha;
+  private final ReceivePackRefCache receivePackRefCache;
   private final ListMultimap<ObjectId, String> groups;
   private final SetMultimap<String, String> groupAliases;
   private final Lookup groupLookup;
 
   private boolean done;
 
+  /**
+   * Returns a new {@link GroupCollector} instance.
+   *
+   * @see GroupCollector for what this class does.
+   */
   public static GroupCollector create(
-      ListMultimap<ObjectId, Ref> changeRefsById,
+      ReceivePackRefCache receivePackRefCache,
       PatchSetUtil psUtil,
       ChangeNotes.Factory notesFactory,
       Project.NameKey project) {
     return new GroupCollector(
-        transformRefs(changeRefsById),
+        receivePackRefCache,
         psId -> {
           // TODO(dborowitz): Reuse open repository from caller.
           ChangeNotes notes = notesFactory.createChecked(project, psId.changeId());
@@ -113,31 +120,32 @@
         });
   }
 
-  private GroupCollector(ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha, Lookup groupLookup) {
-    this.patchSetsBySha = patchSetsBySha;
+  /**
+   * Returns a new {@link GroupCollector} instance.
+   *
+   * <p>Used in production code by using {@link com.google.gerrit.server.notedb.ChangeNotes.Factory}
+   * to get a group SHA1 (40 bytes string representation) from a {@link
+   * com.google.gerrit.entities.PatchSet.Id}. Unit tests use this method directly by passing their
+   * own lookup function.
+   *
+   * @see GroupCollector for what this class does.
+   */
+  @VisibleForTesting
+  GroupCollector(ReceivePackRefCache receivePackRefCache, Lookup groupLookup) {
+    this.receivePackRefCache = receivePackRefCache;
     this.groupLookup = groupLookup;
     groups = MultimapBuilder.hashKeys().arrayListValues().build();
     groupAliases = MultimapBuilder.hashKeys().hashSetValues().build();
   }
 
-  private static ListMultimap<ObjectId, PatchSet.Id> transformRefs(
-      ListMultimap<ObjectId, Ref> refs) {
-    return Multimaps.transformValues(refs, r -> PatchSet.Id.fromRef(r.getName()));
-  }
-
-  @VisibleForTesting
-  GroupCollector(
-      ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha,
-      ListMultimap<PatchSet.Id, String> groupLookup) {
-    this(
-        patchSetsBySha,
-        psId -> {
-          List<String> groups = groupLookup.get(psId);
-          return !groups.isEmpty() ? groups : null;
-        });
-  }
-
-  public void visit(RevCommit c) {
+  /**
+   * Process the given {@link RevCommit}. Callers must call {@link #visit(RevCommit)} on all commits
+   * between the current branch tip and the tip of a push, in reverse topo order (parents before
+   * children). Once all commits have been visited, call {@link #getGroups()} for the result.
+   *
+   * @see GroupCollector for what this class does.
+   */
+  public void visit(RevCommit c) throws IOException {
     checkState(!done, "visit() called after getGroups()");
     Set<RevCommit> interestingParents = getInterestingParents(c);
 
@@ -197,7 +205,10 @@
     }
   }
 
-  public SortedSetMultimap<ObjectId, String> getGroups() {
+  /**
+   * Returns the groups that got collected from visiting commits using {@link #visit(RevCommit)}.
+   */
+  public SortedSetMultimap<ObjectId, String> getGroups() throws IOException {
     done = true;
     SortedSetMultimap<ObjectId, String> result =
         MultimapBuilder.hashKeys(groups.keySet().size()).treeSetValues().build();
@@ -218,12 +229,13 @@
     return result;
   }
 
-  private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) {
+  private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
     ObjectId id = parseGroup(commit, group);
-    return id != null && patchSetsBySha.containsKey(id);
+    return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
   }
 
-  private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates) {
+  private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
+      throws IOException {
     Set<String> actual = Sets.newTreeSet();
     Set<String> done = Sets.newHashSetWithExpectedSize(candidates.size());
     Set<String> seen = Sets.newHashSetWithExpectedSize(candidates.size());
@@ -258,16 +270,20 @@
     }
   }
 
-  private Iterable<String> resolveGroup(ObjectId forCommit, String group) {
+  private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
-      PatchSet.Id psId = Iterables.getFirst(patchSetsBySha.get(id), null);
-      if (psId != null) {
-        List<String> groups = groupLookup.lookup(psId);
-        // Group for existing patch set may be missing, e.g. if group has not
-        // been migrated yet.
-        if (groups != null && !groups.isEmpty()) {
-          return groups;
+      Ref ref =
+          Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
+      if (ref != null) {
+        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+        if (psId != null) {
+          List<String> groups = groupLookup.lookup(psId);
+          // Group for existing patch set may be missing, e.g. if group has not
+          // been migrated yet.
+          if (groups != null && !groups.isEmpty()) {
+            return groups;
+          }
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
index 8f7e684..8421e54 100644
--- a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
+++ b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
@@ -26,10 +26,13 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.annotations.NonNull;
 import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefRename;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -156,4 +159,17 @@
     }
     return null;
   }
+
+  @Override
+  @NonNull
+  public Set<Ref> getTipsWithSha1(ObjectId id) throws IOException {
+    Set<Ref> unfiltered = super.getTipsWithSha1(id);
+    Set<Ref> result = new HashSet<>(unfiltered.size());
+    for (Ref ref : unfiltered) {
+      if (exactRef(ref.getName()) != null) {
+        result.add(ref);
+      }
+    }
+    return result;
+  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index d89bb63..2b04d4d 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -2,9 +2,13 @@
 
 java_library(
     name = "receive",
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["ReceivePackRefCache.java"],
+    ),
     visibility = ["//visibility:public"],
     deps = [
+        ":ref_cache",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
@@ -26,3 +30,14 @@
         "//lib/guice:guice-assistedinject",
     ],
 )
+
+java_library(
+    name = "ref_cache",
+    srcs = glob(["ReceivePackRefCache.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//lib:guava",
+        "//lib:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index c6c9b39..7dd21e1 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -343,7 +343,6 @@
   private final SetPrivateOp.Factory setPrivateOpFactory;
 
   // Assisted injected fields.
-  private final AllRefsWatcher allRefsWatcher;
   private final ProjectState projectState;
   private final IdentifiedUser user;
   private final ReceivePack receivePack;
@@ -363,12 +362,9 @@
   private final ListMultimap<String, String> errors;
 
   private final ListMultimap<String, String> pushOptions;
+  private final ReceivePackRefCache receivePackRefCache;
   private final Map<Change.Id, ReplaceRequest> replaceByChange;
 
-  // Collections lazily populated during processing.
-  private ListMultimap<Change.Id, Ref> refsByChange;
-  private ListMultimap<ObjectId, Ref> refsById;
-
   // Other settings populated during processing.
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
@@ -469,7 +465,6 @@
     this.setPrivateOpFactory = setPrivateOpFactory;
 
     // Assisted injected fields.
-    this.allRefsWatcher = allRefsWatcher;
     this.projectState = projectState;
     this.user = user;
     this.receivePack = rp;
@@ -501,6 +496,13 @@
     this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender();
     this.resultChangeIds = resultChangeIds;
     this.loggingTags = ImmutableMap.of();
+
+    // TODO(hiesel): Make this decision implicit once vetted
+    boolean useRefCache = config.getBoolean("receive", "enableInMemoryRefCache", true);
+    receivePackRefCache =
+        useRefCache
+            ? ReceivePackRefCache.withAdvertisedRefs(() -> allRefsWatcher.getAllRefs())
+            : ReceivePackRefCache.noCache(receivePack.getRepository().getRefDatabase());
   }
 
   void init() {
@@ -653,17 +655,27 @@
     Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
 
     List<CreateRequest> newChanges = Collections.emptyList();
-    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-      newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+    try {
+      if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+        try {
+          newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+        } catch (IOException e) {
+          logger.atSevere().withCause(e).log(
+              "Failed to select new changes in %s", project.getName());
+          return;
+        }
+      }
+
+      // Commit validation has already happened, so any changes without Change-Id are for the
+      // deprecated feature.
+      warnAboutMissingChangeId(newChanges);
+      preparePatchSetsForReplace(newChanges);
+      insertChangesAndPatchSets(newChanges, replaceProgress);
+    } finally {
+      newProgress.end();
+      replaceProgress.end();
     }
 
-    // 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(
@@ -1644,8 +1656,9 @@
     /**
      * returns the destination ref of the magic branch, and populates options in the cmdLineParser.
      */
-    String parse(Repository repo, Set<String> refs, ListMultimap<String, String> pushOptions)
-        throws CmdLineException {
+    String parse(
+        Repository repo, ReceivePackRefCache refCache, ListMultimap<String, String> pushOptions)
+        throws CmdLineException, IOException {
       String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
 
       ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
@@ -1675,7 +1688,7 @@
       int split = ref.length();
       for (; ; ) {
         String name = ref.substring(0, split);
-        if (refs.contains(name) || name.equals(head)) {
+        if (refCache.exactRef(name) != null || name.equals(head)) {
           break;
         }
 
@@ -1734,7 +1747,7 @@
    *
    * <p>Assumes we are handling a magic branch here.
    */
-  private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
+  private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException, IOException {
     try (TraceTimer traceTimer = newTimer("parseMagicBranch")) {
       logger.atFine().log("Found magic branch %s", cmd.getRefName());
       MagicBranchInput magicBranch = new MagicBranchInput(user, projectState, cmd, labelTypes);
@@ -1743,7 +1756,7 @@
       magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
       try {
-        ref = magicBranch.parse(repo, receivePack.getAdvertisedRefs().keySet(), pushOptions);
+        ref = magicBranch.parse(repo, receivePackRefCache, pushOptions);
       } catch (CmdLineException e) {
         if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
           logger.atFine().log("Invalid branch syntax");
@@ -1775,7 +1788,7 @@
       // 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)
+      if (receivePackRefCache.exactRef(ref) == null
           && !ref.equals(readHEAD(repo))
           && !ref.equals(RefNames.REFS_CONFIG)) {
         logger.atFine().log("Ref %s not found", ref);
@@ -1850,11 +1863,12 @@
             reject(cmd, "cannot use merged with base");
             return;
           }
-          RevCommit branchTip = readBranchTip(magicBranch.dest);
-          if (branchTip == null) {
+          Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch());
+          if (refTip == null) {
             reject(cmd, magicBranch.dest.branch() + " not found");
             return;
           }
+          RevCommit branchTip = receivePack.getRevWalk().parseCommit(refTip.getObjectId());
           if (!walk.isMergedInto(tip, branchTip)) {
             reject(cmd, "not merged into branch");
             return;
@@ -1891,8 +1905,9 @@
             }
           }
         } else if (newChangeForAllNotInTarget) {
-          RevCommit branchTip = readBranchTip(magicBranch.dest);
-          if (branchTip != null) {
+          Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch());
+          if (refTip != null) {
+            RevCommit branchTip = receivePack.getRevWalk().parseCommit(refTip.getObjectId());
             magicBranch.baseCommit = Collections.singletonList(branchTip);
             logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
           } else {
@@ -1939,7 +1954,7 @@
         newTimer("validateConnected", Metadata.builder().branchName(dest.branch()))) {
       RevWalk walk = receivePack.getRevWalk();
       try {
-        Ref targetRef = receivePack.getAdvertisedRefs().get(dest.branch());
+        Ref targetRef = receivePackRefCache.exactRef(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
@@ -1986,14 +2001,6 @@
     }
   }
 
-  private RevCommit readBranchTip(BranchNameKey branch) throws IOException {
-    Ref r = allRefs().get(branch.branch());
-    if (r == null) {
-      return null;
-    }
-    return receivePack.getRevWalk().parseCommit(r.getObjectId());
-  }
-
   /**
    * Update an existing change. If draft comments are to be published, these are validated and may
    * be withheld.
@@ -2001,7 +2008,8 @@
    * @return True if the command succeeded, false if it was rejected.
    */
   private boolean requestReplaceAndValidateComments(
-      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
+      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit)
+      throws IOException {
     try (TraceTimer traceTimer = newTimer("requestReplaceAndValidateComments")) {
       if (change.isClosed()) {
         reject(
@@ -2063,14 +2071,14 @@
     }
   }
 
-  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) {
+  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
+      throws IOException {
     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());
+          GroupCollector.create(receivePackRefCache, psUtil, notesFactory, project.getNameKey());
 
       BranchCommitValidator validator =
           commitValidatorFactory.create(projectState, magicBranch.dest, user);
@@ -2111,7 +2119,8 @@
           receivePack.getRevWalk().parseBody(c);
           String name = c.name();
           groupCollector.visit(c);
-          Collection<Ref> existingRefs = existing.get(c);
+          Collection<Ref> existingRefs =
+              receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
 
           if (rejectImplicitMerges) {
             Collections.addAll(mergedParents, c.getParents());
@@ -2275,7 +2284,8 @@
 
             // In case the change look up from the index failed,
             // double check against the existing refs
-            if (foundInExistingRef(existing.get(p.commit))) {
+            if (foundInExistingRef(
+                receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
                 return Collections.emptyList();
@@ -2382,7 +2392,7 @@
       for (RevCommit c : magicBranch.baseCommit) {
         receivePack.getRevWalk().markUninteresting(c);
       }
-      Ref targetRef = allRefs().get(magicBranch.dest.branch());
+      Ref targetRef = receivePackRefCache.exactRef(magicBranch.dest.branch());
       if (targetRef != null) {
         logger.atFine().log(
             "Marking target ref %s (%s) uninteresting",
@@ -2397,7 +2407,7 @@
   private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
     try (TraceTimer traceTimer = newTimer("rejectImplicitMerges")) {
       if (!mergedParents.isEmpty()) {
-        Ref targetRef = allRefs().get(magicBranch.dest.branch());
+        Ref targetRef = receivePackRefCache.exactRef(magicBranch.dest.branch());
         if (targetRef != null) {
           RevWalk rw = receivePack.getRevWalk();
           RevCommit tip = rw.parseCommit(targetRef.getObjectId());
@@ -2432,13 +2442,15 @@
 
   // 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) {
+  private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) throws IOException {
     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) {
+      for (Ref ref :
+          Iterables.concat(
+              receivePackRefCache.byPrefix(R_HEADS),
+              Collections.singletonList(receivePackRefCache.exactRef(forRef)))) {
+        if (ref != null && ref.getObjectId() != null) {
           try {
             rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
             i++;
@@ -2703,7 +2715,8 @@
     ReplaceOp replaceOp;
 
     ReplaceRequest(
-        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
+        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto)
+        throws IOException {
       this.ontoChange = toChange;
       this.newCommitId = newCommit.copy();
       this.inputCommand = requireNonNull(cmd);
@@ -2715,11 +2728,12 @@
         revCommit = null;
       }
       revisions = HashBiMap.create();
-      for (Ref ref : refs(toChange)) {
+      for (Ref ref : receivePackRefCache.byPrefix(RefNames.changeRefPrefix(toChange))) {
         try {
-          revisions.forcePut(
-              receivePack.getRevWalk().parseCommit(ref.getObjectId()),
-              PatchSet.Id.fromRef(ref.getName()));
+          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+          if (psId != null) {
+            revisions.forcePut(receivePack.getRevWalk().parseCommit(ref.getObjectId()), psId);
+          }
         } catch (IOException err) {
           logger.atWarning().withCause(err).log(
               "Project %s contains invalid change ref %s", project.getName(), ref.getName());
@@ -2785,6 +2799,16 @@
         Change change = notes.getChange();
         priorPatchSet = change.currentPatchSetId();
         if (!revisions.containsValue(priorPatchSet)) {
+          logger.atWarning().log(
+              "Change %d is missing revision for patch set %s"
+                  + " (it has revisions for these patch sets: %s)",
+              change.getChangeId(),
+              priorPatchSet.getId(),
+              Iterables.toString(
+                  revisions.values().stream()
+                      .limit(100) // Enough for "normal" changes.
+                      .map(PatchSet.Id::getId)
+                      .collect(Collectors.toList())));
           reject(inputCommand, "change " + ontoChange + " missing revisions");
           return false;
         }
@@ -2812,11 +2836,16 @@
           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;
-          }
+        List<Ref> existingChangesWithSameCommit =
+            receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
+        if (!existingChangesWithSameCommit.isEmpty()) {
+          // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
+          //  without the option to turn that off.
+          reject(
+              inputCommand,
+              "commit already exists (in the project): "
+                  + existingChangesWithSameCommit.get(0).getName());
+          return false;
         }
 
         try (TraceTimer traceTimer2 = newTimer("validateNewPatchSetNoteDb#isMergedInto")) {
@@ -2950,14 +2979,20 @@
     private void newPatchSet() throws IOException {
       try (TraceTimer traceTimer = newTimer("newPatchSet")) {
         RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
-        psId =
-            ChangeUtil.nextPatchSetIdFromAllRefsMap(
-                allRefs(), notes.getChange().currentPatchSetId());
+        psId = nextPatchSetId(notes.getChange().currentPatchSetId());
         info = patchSetInfoFactory.get(receivePack.getRevWalk(), newCommit, psId);
         cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
       }
     }
 
+    private PatchSet.Id nextPatchSetId(PatchSet.Id psId) throws IOException {
+      PatchSet.Id next = ChangeUtil.nextPatchSetId(psId);
+      while (receivePackRefCache.exactRef(next.toRefName()) != null) {
+        next = ChangeUtil.nextPatchSetId(next);
+      }
+      return next;
+    }
+
     void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
       try (TraceTimer traceTimer = newTimer("addOps")) {
         if (magicBranch != null && magicBranch.edit) {
@@ -3091,45 +3126,6 @@
     }
   }
 
-  private List<Ref> refs(Change.Id changeId) {
-    return refsByChange().get(changeId);
-  }
-
-  private void initChangeRefMaps() {
-    if (refsByChange != null) {
-      return;
-    }
-
-    try (TraceTimer traceTimer = newTimer("initChangeRefMaps")) {
-      int estRefsPerChange = 4;
-      refsById = MultimapBuilder.hashKeys().arrayListValues().build();
-      refsByChange =
-          MultimapBuilder.hashKeys(allRefs().size() / estRefsPerChange)
-              .arrayListValues(estRefsPerChange)
-              .build();
-      for (Ref ref : allRefs().values()) {
-        ObjectId obj = ref.getObjectId();
-        if (obj != null) {
-          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-          if (psId != null) {
-            refsById.put(obj, ref);
-            refsByChange.put(psId.changeId(), ref);
-          }
-        }
-      }
-    }
-  }
-
-  private ListMultimap<Change.Id, Ref> refsByChange() {
-    initChangeRefMaps();
-    return refsByChange;
-  }
-
-  private ListMultimap<ObjectId, Ref> changeRefsById() {
-    initChangeRefMaps();
-    return refsById;
-  }
-
   private static boolean parentsEqual(RevCommit a, RevCommit b) {
     if (a.getParentCount() != b.getParentCount()) {
       return false;
@@ -3214,7 +3210,6 @@
         if (!(parsedObject instanceof RevCommit)) {
           return;
         }
-        ListMultimap<ObjectId, Ref> existing = changeRefsById();
         walk.markStart((RevCommit) parsedObject);
         markHeadsAsUninteresting(walk, cmd.getRefName());
         int limit = receiveConfig.maxBatchCommits;
@@ -3231,7 +3226,7 @@
                     "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
             return;
           }
-          if (existing.keySet().contains(c)) {
+          if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
             continue;
           }
 
@@ -3281,7 +3276,6 @@
                   rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
                 }
 
-                ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
                 Map<Change.Key, ChangeNotes> byKey = null;
                 List<ReplaceRequest> replaceAndClose = new ArrayList<>();
 
@@ -3291,7 +3285,8 @@
                 for (RevCommit c; (c = rw.next()) != null; ) {
                   rw.parseBody(c);
 
-                  for (Ref ref : byCommit.get(c.copy())) {
+                  for (Ref ref :
+                      receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
                     PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
                     Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
                     if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
@@ -3403,13 +3398,6 @@
     }
   }
 
-  // allRefsWatcher hooks into the protocol negotation to get a list of all known refs.
-  // This is used as a cache of ref -> sha1 values, and to build an inverse index
-  // of (change => list of refs) and a (SHA1 => refs).
-  private Map<String, Ref> allRefs() {
-    return allRefsWatcher.getAllRefs();
-  }
-
   private TraceTimer newTimer(String name) {
     return newTimer(getClass(), name);
   }
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
new file mode 100644
index 0000000..376ab2d
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -0,0 +1,174 @@
+// 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 static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+
+/**
+ * Simple cache for accessing refs by name, prefix or {@link ObjectId}. Intended to be used when
+ * processing a {@code git push}.
+ *
+ * <p>This class is not thread safe.
+ */
+public interface ReceivePackRefCache {
+
+  /**
+   * Returns an instance that delegates all calls to the provided {@link RefDatabase}. To be used in
+   * tests or when the ref database is fast with forward (name to {@link ObjectId}) and inverse
+   * ({@code ObjectId} to name) lookups.
+   */
+  static ReceivePackRefCache noCache(RefDatabase delegate) {
+    return new NoCache(delegate);
+  }
+
+  /**
+   * Returns an instance that answers calls based on refs previously advertised and captured in
+   * {@link AllRefsWatcher}. Speeds up inverse lookups by building a {@code Map<ObjectId,
+   * List<Ref>>} and a {@code Map<Change.Id, List<Ref>>}.
+   *
+   * <p>This implementation speeds up lookups when the ref database does not support inverse ({@code
+   * ObjectId} to name) lookups.
+   */
+  static ReceivePackRefCache withAdvertisedRefs(Supplier<Map<String, Ref>> allRefsSupplier) {
+    return new WithAdvertisedRefs(allRefsSupplier);
+  }
+
+  /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
+  ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+
+  /** Returns all refs whose name starts with {@code prefix}. */
+  ImmutableList<Ref> byPrefix(String prefix) throws IOException;
+
+  /** Returns a ref whose name matches {@code ref} or {@code null} if such a ref does not exist. */
+  @Nullable
+  Ref exactRef(String ref) throws IOException;
+
+  class NoCache implements ReceivePackRefCache {
+    private final RefDatabase delegate;
+
+    private NoCache(RefDatabase delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
+        throws IOException {
+      return delegate.getTipsWithSha1(id).stream()
+          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .collect(toImmutableList());
+    }
+
+    @Override
+    public ImmutableList<Ref> byPrefix(String prefix) throws IOException {
+      return delegate.getRefsByPrefix(prefix).stream().collect(toImmutableList());
+    }
+
+    @Override
+    @Nullable
+    public Ref exactRef(String name) throws IOException {
+      return delegate.exactRef(name);
+    }
+  }
+
+  class WithAdvertisedRefs implements ReceivePackRefCache {
+    /** We estimate that a change has an average of 4 patch sets plus the meta ref. */
+    private static final int ESTIMATED_NUMBER_OF_REFS_PER_CHANGE = 5;
+
+    private final Supplier<Map<String, Ref>> allRefsSupplier;
+
+    // Collections lazily populated during processing.
+    private Map<String, Ref> allRefs;
+    /** Contains only patch set refs. */
+    private ListMultimap<Change.Id, Ref> refsByChange;
+    /** Contains all refs. */
+    private ListMultimap<ObjectId, Ref> refsByObjectId;
+
+    private WithAdvertisedRefs(Supplier<Map<String, Ref>> allRefsSupplier) {
+      this.allRefsSupplier = allRefsSupplier;
+    }
+
+    @Override
+    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+      lazilyInitRefMaps();
+      return refsByObjectId.get(id).stream()
+          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .collect(toImmutableList());
+    }
+
+    @Override
+    public ImmutableList<Ref> byPrefix(String prefix) {
+      lazilyInitRefMaps();
+      if (RefNames.isRefsChanges(prefix)) {
+        Change.Id cId = Change.Id.fromRefPart(prefix);
+        if (cId != null) {
+          return refsByChange.get(cId).stream()
+              .filter(r -> r.getName().startsWith(prefix))
+              .collect(toImmutableList());
+        }
+      }
+      return allRefs().values().stream()
+          .filter(r -> r.getName().startsWith(prefix))
+          .collect(toImmutableList());
+    }
+
+    @Override
+    @Nullable
+    public Ref exactRef(String name) {
+      return allRefs().get(name);
+    }
+
+    private Map<String, Ref> allRefs() {
+      if (allRefs == null) {
+        allRefs = allRefsSupplier.get();
+      }
+      return allRefs;
+    }
+
+    private void lazilyInitRefMaps() {
+      if (refsByChange != null) {
+        return;
+      }
+
+      refsByObjectId = MultimapBuilder.hashKeys().arrayListValues().build();
+      refsByChange =
+          MultimapBuilder.hashKeys(allRefs().size() / ESTIMATED_NUMBER_OF_REFS_PER_CHANGE)
+              .arrayListValues(ESTIMATED_NUMBER_OF_REFS_PER_CHANGE)
+              .build();
+      for (Ref ref : allRefs().values()) {
+        ObjectId objectId = ref.getObjectId();
+        if (objectId != null) {
+          refsByObjectId.put(objectId, ref);
+          Change.Id changeId = Change.Id.fromRef(ref.getName());
+          if (changeId != null) {
+            refsByChange.put(changeId, ref);
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/index/StalenessCheckResult.java b/java/com/google/gerrit/server/index/StalenessCheckResult.java
new file mode 100644
index 0000000..cd3f592
--- /dev/null
+++ b/java/com/google/gerrit/server/index/StalenessCheckResult.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.index;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+
+/** Structured result of a staleness check. */
+@AutoValue
+public abstract class StalenessCheckResult {
+
+  public static StalenessCheckResult notStale() {
+    return new AutoValue_StalenessCheckResult(false, Optional.empty());
+  }
+
+  public static StalenessCheckResult stale(String reason) {
+    return new AutoValue_StalenessCheckResult(true, Optional.of(reason));
+  }
+
+  public static StalenessCheckResult stale(String reason, Object... args) {
+    return stale(String.format(reason, args));
+  }
+
+  public abstract boolean isStale();
+
+  public abstract Optional<String> reason();
+}
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index b908846..5aafec8 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -116,7 +117,9 @@
   @Override
   public boolean reindexIfStale(Account.Id id) {
     try {
-      if (stalenessChecker.isStale(id)) {
+      StalenessCheckResult stalenessCheckResult = stalenessChecker.check(id);
+      if (stalenessCheckResult.isStale()) {
+        logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
         index(id);
         return true;
       }
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index aad9527..50fdcde 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -87,16 +88,16 @@
     this.indexConfig = indexConfig;
   }
 
-  public boolean isStale(Account.Id id) throws IOException {
+  public StalenessCheckResult check(Account.Id id) throws IOException {
     AccountIndex i = indexes.getSearchIndex();
     if (i == null) {
       // No index; caller couldn't do anything if it is stale.
-      return false;
+      return StalenessCheckResult.notStale();
     }
     if (!i.getSchema().hasField(AccountField.REF_STATE)
         || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE)) {
       // Index version not new enough for this check.
-      return false;
+      return StalenessCheckResult.notStale();
     }
 
     boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
@@ -112,7 +113,11 @@
         Ref ref = repo.exactRef(RefNames.refsUsers(id));
 
         // Stale if the account actually exists.
-        return ref != null;
+        if (ref == null) {
+          return StalenessCheckResult.notStale();
+        }
+        return StalenessCheckResult.stale(
+            "Document missing in index, but found %s in the repo", ref);
       }
     }
 
@@ -124,8 +129,9 @@
           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;
+          return StalenessCheckResult.stale(
+              "Ref was modified since the account was indexed (%s != %s)",
+              e.getValue(), repo.exactRef(e.getValue().ref()));
         }
       }
     }
@@ -134,17 +140,22 @@
     ListMultimap<ObjectId, ObjectId> extIdStates =
         parseExternalIdStates(result.get().getValue(AccountField.EXTERNAL_ID_STATE));
     if (extIdStates.size() != extIds.size()) {
-      // External IDs of the account were modified since the account was indexed.
-      return true;
+      return StalenessCheckResult.stale(
+          "External IDs of the account were modified since the account was indexed. (%s != %s)",
+          extIdStates.size(), extIds.size());
     }
     for (ExternalId extId : extIds) {
+      if (!extIdStates.containsKey(extId.key().sha1())) {
+        return StalenessCheckResult.stale("External ID missing: %s", extId.key().sha1());
+      }
       if (!extIdStates.containsEntry(extId.key().sha1(), extId.blobId())) {
-        // External IDs of the account were modified since the account was indexed.
-        return true;
+        return StalenessCheckResult.stale(
+            "External ID has unexpected value. (%s != %s)",
+            extIdStates.get(extId.key().sha1()), extId.blobId());
       }
     }
 
-    return false;
+    return StalenessCheckResult.notStale();
   }
 
   public static ListMultimap<ObjectId, ObjectId> parseExternalIdStates(
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index f6d86bf..5211a07 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -432,7 +433,9 @@
     public Boolean callImpl() throws Exception {
       remove();
       try {
-        if (stalenessChecker.isStale(id)) {
+        StalenessCheckResult stalenessCheckResult = stalenessChecker.check(id);
+        if (stalenessCheckResult.isStale()) {
+          logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
           indexImpl(changeDataFactory.create(project, id));
           return true;
         }
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 47fd7ba..236163d 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -68,27 +69,28 @@
     this.indexConfig = indexConfig;
   }
 
-  public boolean isStale(Change.Id id) {
+  public StalenessCheckResult check(Change.Id id) {
     ChangeIndex i = indexes.getSearchIndex();
     if (i == null) {
-      return false; // No index; caller couldn't do anything if it is stale.
+      return StalenessCheckResult
+          .notStale(); // No index; caller couldn't do anything if it is stale.
     }
     if (!i.getSchema().hasField(ChangeField.REF_STATE)
         || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
-      return false; // Index version not new enough for this check.
+      return StalenessCheckResult.notStale(); // Index version not new enough for this check.
     }
 
     Optional<ChangeData> result =
         i.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, FIELDS));
     if (!result.isPresent()) {
-      return true; // Not in index, but caller wants it to be.
+      return StalenessCheckResult.stale("Document %s missing from index", id);
     }
     ChangeData cd = result.get();
-    return isStale(repoManager, id, parseStates(cd), parsePatterns(cd));
+    return check(repoManager, id, parseStates(cd), parsePatterns(cd));
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
-  public static boolean isStale(
+  public static StalenessCheckResult check(
       GitRepositoryManager repoManager,
       Change.Id id,
       SetMultimap<Project.NameKey, RefState> states,
@@ -97,7 +99,7 @@
   }
 
   @VisibleForTesting
-  static boolean refsAreStale(
+  static StalenessCheckResult refsAreStale(
       GitRepositoryManager repoManager,
       Change.Id id,
       SetMultimap<Project.NameKey, RefState> states,
@@ -105,12 +107,13 @@
     Set<Project.NameKey> projects = Sets.union(states.keySet(), patterns.keySet());
 
     for (Project.NameKey p : projects) {
-      if (refsAreStale(repoManager, id, p, states, patterns)) {
-        return true;
+      StalenessCheckResult result = refsAreStale(repoManager, id, p, states, patterns);
+      if (result.isStale()) {
+        return result;
       }
     }
 
-    return false;
+    return StalenessCheckResult.notStale();
   }
 
   private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
@@ -136,7 +139,7 @@
     return result;
   }
 
-  private static boolean refsAreStale(
+  private static StalenessCheckResult refsAreStale(
       GitRepositoryManager repoManager,
       Change.Id id,
       Project.NameKey project,
@@ -146,18 +149,22 @@
       Set<RefState> states = allStates.get(project);
       for (RefState state : states) {
         if (!state.match(repo)) {
-          return true;
+          return StalenessCheckResult.stale(
+              "Ref states don't match for document %s (%s != %s)",
+              id, state, repo.exactRef(state.ref()));
         }
       }
       for (RefStatePattern pattern : allPatterns.get(project)) {
         if (!pattern.match(repo, states)) {
-          return true;
+          return StalenessCheckResult.stale(
+              "Ref patterns don't match for document %s. Pattern: %s States: %s",
+              id, pattern, states);
         }
       }
-      return false;
+      return StalenessCheckResult.notStale();
     } catch (IOException e) {
       logger.atWarning().withCause(e).log("error checking staleness of %s in %s", id, project);
-      return true;
+      return StalenessCheckResult.stale("Exceptions while processing document %s", e.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index 790066d..70dc8fa 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -116,7 +117,9 @@
   @Override
   public boolean reindexIfStale(AccountGroup.UUID uuid) {
     try {
-      if (stalenessChecker.isStale(uuid)) {
+      StalenessCheckResult stalenessCheckResult = stalenessChecker.check(uuid);
+      if (stalenessCheckResult.isStale()) {
+        logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
         index(uuid);
         return true;
       }
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 3a721c3..54a6f85 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -59,10 +60,11 @@
     this.allUsers = allUsers;
   }
 
-  public boolean isStale(AccountGroup.UUID uuid) throws IOException {
+  public StalenessCheckResult check(AccountGroup.UUID uuid) throws IOException {
     GroupIndex i = indexes.getSearchIndex();
     if (i == null) {
-      return false; // No index; caller couldn't do anything if it is stale.
+      // No index; caller couldn't do anything if it is stale.
+      return StalenessCheckResult.notStale();
     }
 
     Optional<FieldBundle> result =
@@ -73,14 +75,23 @@
         Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
 
         // Stale if the group actually exists.
-        return ref != null;
+        if (ref == null) {
+          return StalenessCheckResult.notStale();
+        }
+        return StalenessCheckResult.stale(
+            "Document missing in index, but found %s in the repo", ref);
       }
     }
 
     try (Repository repo = repoManager.openRepository(allUsers)) {
       Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
       ObjectId head = ref == null ? ObjectId.zeroId() : ref.getObjectId();
-      return !head.equals(ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0));
+      ObjectId idFromIndex = ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0);
+      if (head.equals(idFromIndex)) {
+        return StalenessCheckResult.notStale();
+      }
+      return StalenessCheckResult.stale(
+          "Document has unexpected ref state (%s != %s)", head, idFromIndex);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index e4c1a7d..e325a33 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -27,6 +27,7 @@
 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.server.index.StalenessCheckResult;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import java.util.Optional;
@@ -47,17 +48,18 @@
     this.indexConfig = indexConfig;
   }
 
-  public boolean isStale(Project.NameKey project) {
+  public StalenessCheckResult check(Project.NameKey project) {
     ProjectData projectData = projectCache.get(project).toProjectData();
     ProjectIndex i = indexes.getSearchIndex();
     if (i == null) {
-      return false; // No index; caller couldn't do anything if it is stale.
+      return StalenessCheckResult
+          .notStale(); // No index; caller couldn't do anything if it is stale.
     }
 
     Optional<FieldBundle> result =
         i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
     if (!result.isPresent()) {
-      return true;
+      return StalenessCheckResult.stale("Document %s missing from index", project);
     }
 
     SetMultimap<Project.NameKey, RefState> indexedRefStates =
@@ -73,6 +75,10 @@
                     p.getProject().getNameKey(),
                     RefState.create(RefNames.REFS_CONFIG, p.getProject().getConfigRefState())));
 
-    return !currentRefStates.equals(indexedRefStates);
+    if (currentRefStates.equals(indexedRefStates)) {
+      return StalenessCheckResult.notStale();
+    }
+    return StalenessCheckResult.stale(
+        "Document has unexpected ref states (%s != %s)", currentRefStates, indexedRefStates);
   }
 }
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 7af204e..60e41ed 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -92,6 +92,9 @@
   // The version of a secondary index.
   public abstract Optional<Integer> indexVersion();
 
+  // The number of inputs to an operation, eg. Reachable.fromRefs.
+  public abstract Optional<Integer> inputSize();
+
   // The name of the implementation method.
   public abstract Optional<String> methodName();
 
@@ -295,6 +298,8 @@
 
     public abstract Builder indexVersion(int indexVersion);
 
+    public abstract Builder inputSize(int size);
+
     public abstract Builder methodName(@Nullable String methodName);
 
     public abstract Builder multiple(boolean multiple);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 9c45aaf..3322b68 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -363,6 +363,15 @@
       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);
@@ -410,10 +419,6 @@
 
     previousWorkInProgressFooter = null;
     parseWorkInProgress(commit);
-
-    if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
-      lastUpdatedOn = ts;
-    }
   }
 
   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -487,10 +492,6 @@
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
     if (patchSetCommitParsed(psId)) {
-      if (deletedPatchSets.contains(psId)) {
-        // Do not update PS details as PS was deleted and this meta data is of no relevance.
-        return;
-      }
       ObjectId commitId = patchSets.get(psId).commitId().orElseThrow(IllegalStateException::new);
       throw new ConfigInvalidException(
           String.format(
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index f3c8eab..0f228fe 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -53,6 +53,7 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 
 class PatchScriptBuilder {
+
   static final int MAX_CONTEXT = 5000000;
   static final int BIG_FILE = 9000;
 
@@ -66,10 +67,6 @@
   private ComparisonType comparisonType;
   private ObjectId aId;
   private ObjectId bId;
-
-  private final Side a;
-  private final Side b;
-
   private List<Edit> edits;
   private final FileTypeRegistry registry;
   private final PatchListCache patchListCache;
@@ -77,8 +74,6 @@
 
   @Inject
   PatchScriptBuilder(FileTypeRegistry ftr, PatchListCache plc) {
-    a = new Side();
-    b = new Side();
     registry = ftr;
     patchListCache = plc;
   }
@@ -124,11 +119,9 @@
     boolean intralineFailure = false;
     boolean intralineTimeout = false;
 
-    a.path = oldName(content);
-    b.path = newName(content);
-
-    a.resolve(null, aId);
-    b.resolve(a, bId);
+    SideResolver resolver = new SideResolver();
+    Side a = resolver.resolve(oldName(content), null, aId);
+    Side b = resolver.resolve(newName(content), a, bId);
 
     edits = new ArrayList<>(content.getEdits());
     ImmutableSet<Edit> editsDueToRebase = content.getEditsDueToRebase();
@@ -161,7 +154,7 @@
       }
     }
 
-    correctForDifferencesInNewlineAtEnd();
+    correctForDifferencesInNewlineAtEnd(a, b);
 
     if (comments != null) {
       ensureCommentsVisible(comments);
@@ -193,7 +186,7 @@
       //
       context = MAX_CONTEXT;
 
-      packContent(diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE);
+      packContent(a, b, diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE);
     }
 
     return new PatchScript(
@@ -267,7 +260,7 @@
     }
   }
 
-  private void correctForDifferencesInNewlineAtEnd() {
+  private void correctForDifferencesInNewlineAtEnd(Side a, Side b) {
     // a.src.size() is the size ignoring a newline at the end whereas a.size() considers it.
     int aSize = a.src.size();
     int bSize = b.src.size();
@@ -280,7 +273,7 @@
     }
 
     Optional<Edit> lastEdit = getLast(edits);
-    if (isNewlineAtEndDeleted()) {
+    if (isNewlineAtEndDeleted(a, b)) {
       Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndA() == aSize);
       if (lastLineEdit.isPresent()) {
         lastLineEdit.get().extendA();
@@ -288,7 +281,7 @@
         Edit newlineEdit = new Edit(aSize, aSize + 1, bSize, bSize);
         edits.add(newlineEdit);
       }
-    } else if (isNewlineAtEndAdded()) {
+    } else if (isNewlineAtEndAdded(a, b)) {
       Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndB() == bSize);
       if (lastLineEdit.isPresent()) {
         lastLineEdit.get().extendB();
@@ -303,11 +296,11 @@
     return list.isEmpty() ? Optional.empty() : Optional.ofNullable(list.get(list.size() - 1));
   }
 
-  private boolean isNewlineAtEndDeleted() {
+  private boolean isNewlineAtEndDeleted(Side a, Side b) {
     return !a.src.isMissingNewlineAtEnd() && b.src.isMissingNewlineAtEnd();
   }
 
-  private boolean isNewlineAtEndAdded() {
+  private boolean isNewlineAtEndAdded(Side a, Side b) {
     return a.src.isMissingNewlineAtEnd() && !b.src.isMissingNewlineAtEnd();
   }
 
@@ -425,7 +418,7 @@
     return last.getEndA() + (b - last.getEndB());
   }
 
-  private void packContent(boolean ignoredWhitespace) {
+  private void packContent(Side a, Side b, boolean ignoredWhitespace) {
     EditList list = new EditList(edits, context, a.size(), b.size());
     for (EditList.Hunk hunk : list.getHunks()) {
       while (hunk.next()) {
@@ -459,16 +452,38 @@
     }
   }
 
-  private class Side {
-    String path;
-    ObjectId id;
-    FileMode mode;
-    byte[] srcContent;
-    Text src;
-    MimeType mimeType = MimeUtil2.UNKNOWN_MIME_TYPE;
-    DisplayMethod displayMethod = DisplayMethod.DIFF;
-    PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
-    final SparseFileContent dst = new SparseFileContent();
+  private static class Side {
+
+    final String path;
+    final ObjectId id;
+    final FileMode mode;
+    final byte[] srcContent;
+    final Text src;
+    final MimeType mimeType;
+    final DisplayMethod displayMethod;
+    final PatchScript.FileMode fileMode;
+    final SparseFileContent dst;
+
+    public Side(
+        String path,
+        ObjectId id,
+        FileMode mode,
+        byte[] srcContent,
+        Text src,
+        MimeType mimeType,
+        DisplayMethod displayMethod,
+        PatchScript.FileMode fileMode) {
+      this.path = path;
+      this.id = id;
+      this.mode = mode;
+      this.srcContent = srcContent;
+      this.src = src;
+      this.mimeType = mimeType;
+      this.displayMethod = displayMethod;
+      this.fileMode = fileMode;
+      dst = new SparseFileContent();
+      dst.setSize(size());
+    }
 
     int size() {
       if (src == null) {
@@ -488,110 +503,120 @@
     String getSourceLine(int lineNumber) {
       return lineNumber >= src.size() ? "" : src.getString(lineNumber);
     }
+  }
 
-    void resolve(Side other, ObjectId within) throws IOException {
+  private class SideResolver {
+
+    Side resolve(final String path, final Side other, final ObjectId within) throws IOException {
       try {
-        final boolean reuse;
-        if (Patch.COMMIT_MSG.equals(path)) {
+        boolean isCommitMsg = Patch.COMMIT_MSG.equals(path);
+        boolean isMergeList = Patch.MERGE_LIST.equals(path);
+        if (isCommitMsg || isMergeList) {
           if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
-            id = ObjectId.zeroId();
-            src = Text.EMPTY;
-            srcContent = Text.NO_BYTES;
+            return createSide(
+                path,
+                ObjectId.zeroId(),
+                FileMode.MISSING,
+                Text.NO_BYTES,
+                Text.EMPTY,
+                MimeUtil2.UNKNOWN_MIME_TYPE,
+                DisplayMethod.NONE,
+                false);
+          }
+          Text src =
+              isCommitMsg
+                  ? Text.forCommit(reader, within)
+                  : Text.forMergeList(comparisonType, reader, within);
+          byte[] srcContent = src.getContent();
+          DisplayMethod displayMethod;
+          FileMode mode;
+          if (src == Text.EMPTY) {
             mode = FileMode.MISSING;
             displayMethod = DisplayMethod.NONE;
           } else {
-            id = within;
-            src = Text.forCommit(reader, within);
-            srcContent = src.getContent();
-            if (src == Text.EMPTY) {
-              mode = FileMode.MISSING;
-              displayMethod = DisplayMethod.NONE;
-            } else {
-              mode = FileMode.REGULAR_FILE;
-            }
+            mode = FileMode.REGULAR_FILE;
+            displayMethod = DisplayMethod.DIFF;
           }
-          reuse = false;
-        } else if (Patch.MERGE_LIST.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
-            id = ObjectId.zeroId();
-            src = Text.EMPTY;
-            srcContent = Text.NO_BYTES;
-            mode = FileMode.MISSING;
-            displayMethod = DisplayMethod.NONE;
-          } else {
-            id = within;
-            src = Text.forMergeList(comparisonType, reader, within);
-            srcContent = src.getContent();
-            if (src == Text.EMPTY) {
-              mode = FileMode.MISSING;
-              displayMethod = DisplayMethod.NONE;
-            } else {
-              mode = FileMode.REGULAR_FILE;
-            }
-          }
-          reuse = false;
+          return createSide(
+              path,
+              within,
+              mode,
+              srcContent,
+              src,
+              MimeUtil2.UNKNOWN_MIME_TYPE,
+              displayMethod,
+              false);
+        }
+        final TreeWalk tw = find(path, within);
+        ObjectId id = tw != null ? tw.getObjectId(0) : ObjectId.zeroId();
+        FileMode mode = tw != null ? tw.getFileMode(0) : FileMode.MISSING;
+        boolean reuse =
+            other != null
+                && other.id.equals(id)
+                && (other.mode == mode || isBothFile(other.mode, mode));
+        Text src = null;
+        byte[] srcContent;
+        if (reuse) {
+          srcContent = other.srcContent;
+
+        } else if (mode.getObjectType() == Constants.OBJ_BLOB) {
+          srcContent = Text.asByteArray(db.open(id, Constants.OBJ_BLOB));
+
+        } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
+          String strContent = "Subproject commit " + ObjectId.toString(id);
+          srcContent = strContent.getBytes(UTF_8);
+
         } else {
-          final TreeWalk tw = find(within);
+          srcContent = Text.NO_BYTES;
+        }
+        MimeType mimeType = MimeUtil2.UNKNOWN_MIME_TYPE;
+        DisplayMethod displayMethod = DisplayMethod.DIFF;
+        if (reuse) {
+          mimeType = other.mimeType;
+          displayMethod = other.displayMethod;
+          src = other.src;
 
-          id = tw != null ? tw.getObjectId(0) : ObjectId.zeroId();
-          mode = tw != null ? tw.getFileMode(0) : FileMode.MISSING;
-          reuse =
-              other != null
-                  && other.id.equals(id)
-                  && (other.mode == mode || isBothFile(other.mode, mode));
-
-          if (reuse) {
-            srcContent = other.srcContent;
-
-          } else if (mode.getObjectType() == Constants.OBJ_BLOB) {
-            srcContent = Text.asByteArray(db.open(id, Constants.OBJ_BLOB));
-
-          } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
-            String strContent = "Subproject commit " + ObjectId.toString(id);
-            srcContent = strContent.getBytes(UTF_8);
-
-          } else {
-            srcContent = Text.NO_BYTES;
-          }
-
-          if (reuse) {
-            mimeType = other.mimeType;
-            displayMethod = other.displayMethod;
-            src = other.src;
-
-          } else if (srcContent.length > 0 && FileMode.SYMLINK != mode) {
-            mimeType = registry.getMimeType(path, srcContent);
-            if ("image".equals(mimeType.getMediaType()) && registry.isSafeInline(mimeType)) {
-              displayMethod = DisplayMethod.IMG;
-            }
+        } else if (srcContent.length > 0 && FileMode.SYMLINK != mode) {
+          mimeType = registry.getMimeType(path, srcContent);
+          if ("image".equals(mimeType.getMediaType()) && registry.isSafeInline(mimeType)) {
+            displayMethod = DisplayMethod.IMG;
           }
         }
-
-        if (mode == FileMode.MISSING) {
-          displayMethod = DisplayMethod.NONE;
-        }
-
-        if (!reuse) {
-          if (srcContent == Text.NO_BYTES) {
-            src = Text.EMPTY;
-          } else {
-            src = new Text(srcContent);
-          }
-        }
-
-        dst.setSize(size());
-
-        if (mode == FileMode.SYMLINK) {
-          fileMode = PatchScript.FileMode.SYMLINK;
-        } else if (mode == FileMode.GITLINK) {
-          fileMode = PatchScript.FileMode.GITLINK;
-        }
+        return createSide(path, id, mode, srcContent, src, mimeType, displayMethod, reuse);
       } catch (IOException err) {
         throw new IOException("Cannot read " + within.name() + ":" + path, err);
       }
     }
 
-    private TreeWalk find(ObjectId within)
+    private Side createSide(
+        String path,
+        ObjectId id,
+        FileMode mode,
+        byte[] srcContent,
+        Text src,
+        MimeType mimeType,
+        DisplayMethod displayMethod,
+        boolean reuse) {
+      if (!reuse) {
+        if (srcContent == Text.NO_BYTES) {
+          src = Text.EMPTY;
+        } else {
+          src = new Text(srcContent);
+        }
+      }
+      if (mode == FileMode.MISSING) {
+        displayMethod = DisplayMethod.NONE;
+      }
+      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
+      if (mode == FileMode.SYMLINK) {
+        fileMode = PatchScript.FileMode.SYMLINK;
+      } else if (mode == FileMode.GITLINK) {
+        fileMode = PatchScript.FileMode.GITLINK;
+      }
+      return new Side(path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
+    }
+
+    private TreeWalk find(String path, ObjectId within)
         throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
             IOException {
       if (path == null || within == null) {
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
new file mode 100644
index 0000000..2ecd8c2
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.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.project;
+
+import static java.util.stream.Collectors.toMap;
+
+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.LabelDefinitionInfo;
+
+public class LabelDefinitionJson {
+  public static LabelDefinitionInfo format(Project.NameKey projectName, LabelType labelType) {
+    LabelDefinitionInfo label = new LabelDefinitionInfo();
+    label.name = labelType.getName();
+    label.projectName = projectName.get();
+    label.function = labelType.getFunction().getFunctionName();
+    label.values =
+        labelType.getValues().stream().collect(toMap(LabelValue::formatValue, LabelValue::getText));
+    label.defaultValue = labelType.getDefaultValue();
+    label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
+    label.canOverride = toBoolean(labelType.canOverride());
+    label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
+    label.copyMinScore = toBoolean(labelType.isCopyMinScore());
+    label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
+    label.copyAllScoresIfNoChange = toBoolean(labelType.isCopyAllScoresIfNoChange());
+    label.copyAllScoresIfNoCodeChange = toBoolean(labelType.isCopyAllScoresIfNoCodeChange());
+    label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
+    label.copyAllScoresOnMergeFirstParentUpdate =
+        toBoolean(labelType.isCopyAllScoresOnMergeFirstParentUpdate());
+    label.allowPostSubmit = toBoolean(labelType.allowPostSubmit());
+    label.ignoreSelfApproval = toBoolean(labelType.ignoreSelfApproval());
+    return label;
+  }
+
+  private static Boolean toBoolean(boolean v) {
+    return v ? v : null;
+  }
+
+  private LabelDefinitionJson() {}
+}
diff --git a/java/com/google/gerrit/server/project/LabelResource.java b/java/com/google/gerrit/server/project/LabelResource.java
new file mode 100644
index 0000000..a7a2f07
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelResource.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.project;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class LabelResource implements RestResource {
+  public static final TypeLiteral<RestView<LabelResource>> LABEL_KIND =
+      new TypeLiteral<RestView<LabelResource>>() {};
+
+  private final ProjectResource project;
+  private final LabelType labelType;
+
+  public LabelResource(ProjectResource project, LabelType labelType) {
+    this.project = project;
+    this.labelType = labelType;
+  }
+
+  public ProjectResource getProject() {
+    return project;
+  }
+
+  public LabelType getLabelType() {
+    return labelType;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 44d9d98..4d551a2 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.common.data.Permission.isPermission;
 import static com.google.gerrit.entities.Project.DEFAULT_SUBMIT_TYPE;
 import static com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
@@ -112,10 +113,10 @@
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
 
-  private static final String KEY_MATCH = "match";
+  public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
-  private static final String KEY_LINK = "link";
-  private static final String KEY_ENABLED = "enabled";
+  public static final String KEY_LINK = "link";
+  public static final String KEY_ENABLED = "enabled";
 
   public static final String PROJECT_CONFIG = "project.config";
 
@@ -291,6 +292,11 @@
     commentLinkSections.put(commentLink.name, commentLink);
   }
 
+  public void removeCommentLinkSection(String name) {
+    requireNonNull(name);
+    requireNonNull(commentLinkSections.remove(name));
+  }
+
   private ProjectConfig(Project.NameKey projectName, @Nullable StoredConfig baseConfig) {
     this.projectName = projectName;
     this.baseConfig = baseConfig;
@@ -1488,6 +1494,8 @@
       List<String> refPatterns = label.getRefPatterns();
       if (refPatterns != null && !refPatterns.isEmpty()) {
         rc.setStringList(LABEL, name, KEY_BRANCH, refPatterns);
+      } else {
+        rc.unset(LABEL, name, KEY_BRANCH);
       }
     }
 
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 4ea5d11..c30378b 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -17,6 +17,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.change.IncludedInResolver;
+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.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -58,7 +61,15 @@
               .currentUser()
               .project(project)
               .filter(refs, repo, RefFilterOptions.defaults());
-      return IncludedInResolver.includedInAny(repo, rw, commit, filtered.values());
+
+      // The filtering above already produces a voluminous trace. To separate the permission check
+      // from the reachability check, do the trace here:
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "IncludedInResolver.includedInAny",
+              Metadata.builder().projectName(project.get()).inputSize(refs.size()).build())) {
+        return IncludedInResolver.includedInAny(repo, rw, commit, filtered.values());
+      }
     } catch (IOException | PermissionBackendException e) {
       logger.atSevere().withCause(e).log(
           "Cannot verify permissions to commit object %s in repository %s", commit.name(), project);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index d2fc77d..5d4edc9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.OperatorAliasConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
@@ -219,6 +220,7 @@
     final SubmitDryRun submitDryRun;
     final GroupMembers groupMembers;
     final Provider<AnonymousUser> anonymousUserProvider;
+    final OperatorAliasConfig operatorAliasConfig;
 
     private final Provider<CurrentUser> self;
 
@@ -250,7 +252,8 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         GroupMembers groupMembers,
-        Provider<AnonymousUser> anonymousUserProvider) {
+        Provider<AnonymousUser> anonymousUserProvider,
+        OperatorAliasConfig operatorAliasConfig) {
       this(
           queryProvider,
           rewriter,
@@ -277,7 +280,8 @@
           starredChangesUtil,
           accountCache,
           groupMembers,
-          anonymousUserProvider);
+          anonymousUserProvider,
+          operatorAliasConfig);
     }
 
     private Arguments(
@@ -306,7 +310,8 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         GroupMembers groupMembers,
-        Provider<AnonymousUser> anonymousUserProvider) {
+        Provider<AnonymousUser> anonymousUserProvider,
+        OperatorAliasConfig operatorAliasConfig) {
       this.queryProvider = queryProvider;
       this.rewriter = rewriter;
       this.opFactories = opFactories;
@@ -333,6 +338,7 @@
       this.hasOperands = hasOperands;
       this.groupMembers = groupMembers;
       this.anonymousUserProvider = anonymousUserProvider;
+      this.operatorAliasConfig = operatorAliasConfig;
     }
 
     Arguments asUser(CurrentUser otherUser) {
@@ -362,7 +368,8 @@
           starredChangesUtil,
           accountCache,
           groupMembers,
-          anonymousUserProvider);
+          anonymousUserProvider,
+          operatorAliasConfig);
     }
 
     Arguments asUser(Account.Id otherId) {
@@ -407,6 +414,7 @@
   @Inject
   ChangeQueryBuilder(Arguments args) {
     this(mydef, args);
+    setupAliases();
   }
 
   @VisibleForTesting
@@ -415,6 +423,10 @@
     this.args = args;
   }
 
+  private void setupAliases() {
+    setOperatorAliases(args.operatorAliasConfig.getChangeQueryOperatorAliases());
+  }
+
   public Arguments getArgs() {
     return args;
   }
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index 2ddea2f..c781246 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -145,6 +145,6 @@
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
-    return alreadyPreferred.get() ? Response.ok("") : Response.created("");
+    return alreadyPreferred.get() ? Response.ok() : Response.created();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index d31fd92..74c5bc2 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -18,6 +18,7 @@
 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;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -65,8 +66,8 @@
 
   @Override
   public Response<EditInfo> apply(FixResource fixResource, Void nothing)
-      throws AuthException, ResourceConflictException, IOException, ResourceNotFoundException,
-          PermissionBackendException {
+      throws AuthException, BadRequestException, ResourceConflictException, IOException,
+          ResourceNotFoundException, PermissionBackendException {
     RevisionResource revisionResource = fixResource.getRevisionResource();
     Project.NameKey project = revisionResource.getProject();
     ProjectState projectState = projectCache.checkedGet(project);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 6955d8b..1808fa6 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -107,6 +107,7 @@
    * PUT request with a path was called but change edit wasn't created yet. Change edit is created
    * and PUT handler is called.
    */
+  @Singleton
   public static class Create
       implements RestCollectionCreateView<ChangeResource, ChangeEditResource, Put.Input> {
     private final Put putEdit;
@@ -124,6 +125,7 @@
     }
   }
 
+  @Singleton
   public static class DeleteFile
       implements RestCollectionDeleteMissingView<ChangeResource, ChangeEditResource, Input> {
     private final DeleteContent deleteContent;
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 30cfad6..63f5bbe 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -28,6 +28,7 @@
 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.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
@@ -146,21 +147,18 @@
 
   @Singleton
   public static class DefaultDeleteChangeMessage
-      extends RetryingRestModifyView<ChangeMessageResource, Input, ChangeMessageInfo> {
+      implements RestModifyView<ChangeMessageResource, Input> {
     private final DeleteChangeMessage deleteChangeMessage;
 
     @Inject
-    public DefaultDeleteChangeMessage(
-        DeleteChangeMessage deleteChangeMessage, RetryHelper retryHelper) {
-      super(retryHelper);
+    public DefaultDeleteChangeMessage(DeleteChangeMessage deleteChangeMessage) {
       this.deleteChangeMessage = deleteChangeMessage;
     }
 
     @Override
-    protected Response<ChangeMessageInfo> applyImpl(
-        BatchUpdate.Factory updateFactory, ChangeMessageResource resource, Input input)
-        throws Exception {
-      return deleteChangeMessage.applyImpl(updateFactory, resource, new DeleteChangeMessageInput());
+    public Response<ChangeMessageInfo> apply(ChangeMessageResource resource, Input input)
+        throws RestApiException {
+      return deleteChangeMessage.apply(resource, new DeleteChangeMessageInput());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index de7a683..8478bb5 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -36,7 +37,7 @@
 
 @Singleton
 public class DeletePrivate
-    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, String> {
+    extends RetryingRestModifyView<ChangeResource, InputWithMessage, String> {
   private final PermissionBackend permissionBackend;
   private final SetPrivateOp.Factory setPrivateOpFactory;
 
@@ -52,7 +53,7 @@
 
   @Override
   protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, @Nullable SetPrivateOp.Input input)
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, @Nullable InputWithMessage input)
       throws RestApiException, UpdateException {
     if (!canDeletePrivate(rsrc).value()) {
       throw new AuthException("not allowed to unmark private");
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 857205a..74562c2 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -155,23 +155,23 @@
       psf.setLoadHistory(false);
       psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
       PatchScript ps = psf.call();
-      Content content = new Content(ps);
+      ContentCollector contentCollector = new ContentCollector(ps);
       Set<Edit> editsDueToRebase = ps.getEditsDueToRebase();
       for (Edit edit : ps.getEdits()) {
         if (edit.getType() == Edit.Type.EMPTY) {
           continue;
         }
-        content.addCommon(edit.getBeginA());
+        contentCollector.addCommon(edit.getBeginA());
 
         checkState(
-            content.nextA == edit.getBeginA(),
+            contentCollector.nextA == edit.getBeginA(),
             "nextA = %s; want %s",
-            content.nextA,
+            contentCollector.nextA,
             edit.getBeginA());
         checkState(
-            content.nextB == edit.getBeginB(),
+            contentCollector.nextB == edit.getBeginB(),
             "nextB = %s; want %s",
-            content.nextB,
+            contentCollector.nextB,
             edit.getBeginB());
         switch (edit.getType()) {
           case DELETE:
@@ -180,19 +180,19 @@
             List<Edit> internalEdit =
                 edit instanceof ReplaceEdit ? ((ReplaceEdit) edit).getInternalEdits() : null;
             boolean dueToRebase = editsDueToRebase.contains(edit);
-            content.addDiff(edit.getEndA(), edit.getEndB(), internalEdit, dueToRebase);
+            contentCollector.addDiff(edit.getEndA(), edit.getEndB(), internalEdit, dueToRebase);
             break;
           case EMPTY:
           default:
             throw new IllegalStateException();
         }
       }
-      content.addCommon(ps.getA().size());
+      contentCollector.addCommon(ps.getA().size());
 
       ProjectState state = projectCache.get(resource.getRevision().getChange().getProject());
 
       DiffInfo result = new DiffInfo();
-      String revA = basePatchSet != null ? basePatchSet.refName() : content.commitIdA;
+      String revA = basePatchSet != null ? basePatchSet.refName() : ps.getFileInfoA().commitId;
       String revB =
           resource.getRevision().getEdit().isPresent()
               ? resource.getRevision().getEdit().get().getRefName()
@@ -221,7 +221,7 @@
                 state, result.metaA.name, ps.getFileModeA(), ps.getMimeTypeA());
         result.metaA.lines = ps.getA().size();
         result.metaA.webLinks = getFileWebLinks(state.getProject(), revA, result.metaA.name);
-        result.metaA.commitId = content.commitIdA;
+        result.metaA.commitId = ps.getFileInfoA().commitId;
       }
 
       if (ps.getDisplayMethodB() != DisplayMethod.NONE) {
@@ -232,7 +232,7 @@
                 state, result.metaB.name, ps.getFileModeB(), ps.getMimeTypeB());
         result.metaB.lines = ps.getB().size();
         result.metaB.webLinks = getFileWebLinks(state.getProject(), revB, result.metaB.name);
-        result.metaB.commitId = content.commitIdB;
+        result.metaB.commitId = ps.getFileInfoB().commitId;
       }
 
       if (intraline) {
@@ -253,7 +253,7 @@
       if (ps.getPatchHeader().size() > 0) {
         result.diffHeader = ps.getPatchHeader();
       }
-      result.content = content.lines;
+      result.content = contentCollector.lines;
 
       Response<DiffInfo> r = Response.ok(result);
       if (resource.isCacheable()) {
@@ -297,24 +297,20 @@
     return this;
   }
 
-  private static class Content {
+  private static class ContentCollector {
     final List<ContentEntry> lines;
     final SparseFileContent fileA;
     final SparseFileContent fileB;
     final boolean ignoreWS;
-    final String commitIdA;
-    final String commitIdB;
 
     int nextA;
     int nextB;
 
-    Content(PatchScript ps) {
+    ContentCollector(PatchScript ps) {
       lines = Lists.newArrayListWithExpectedSize(ps.getEdits().size() + 2);
       fileA = ps.getA();
       fileB = ps.getB();
       ignoreWS = ps.isIgnoreWhitespace();
-      commitIdA = ps.getCommitIdA();
-      commitIdB = ps.getCommitIdB();
     }
 
     void addCommon(int end) {
diff --git a/java/com/google/gerrit/server/restapi/change/Ignore.java b/java/com/google/gerrit/server/restapi/change/Ignore.java
index 25cf311..a049e54 100644
--- a/java/com/google/gerrit/server/restapi/change/Ignore.java
+++ b/java/com/google/gerrit/server/restapi/change/Ignore.java
@@ -60,7 +60,7 @@
       if (!isIgnored(rsrc)) {
         stars.ignore(rsrc);
       }
-      return Response.ok("");
+      return Response.ok();
     } catch (MutuallyExclusiveLabelsException e) {
       throw new ResourceConflictException(e.getMessage());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
index bfc9f12..099d0a6 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
@@ -32,18 +32,19 @@
 @Singleton
 public class ListChangeMessages implements RestReadView<ChangeResource> {
   private final ChangeMessagesUtil changeMessagesUtil;
-  private final AccountLoader accountLoader;
+  private final AccountLoader.Factory accountLoaderFactory;
 
   @Inject
   public ListChangeMessages(
       ChangeMessagesUtil changeMessagesUtil, AccountLoader.Factory accountLoaderFactory) {
     this.changeMessagesUtil = changeMessagesUtil;
-    this.accountLoader = accountLoaderFactory.create(true);
+    this.accountLoaderFactory = accountLoaderFactory;
   }
 
   @Override
   public Response<List<ChangeMessageInfo>> apply(ChangeResource resource)
       throws PermissionBackendException {
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
     List<ChangeMessage> messages = changeMessagesUtil.byChange(resource.getNotes());
     List<ChangeMessageInfo> messageInfos =
         messages.stream()
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
index 4c942d2..fa4555b 100644
--- a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
@@ -54,7 +54,7 @@
   public Response<String> apply(ChangeResource rsrc, Input input)
       throws RestApiException, IllegalLabelException {
     stars.markAsReviewed(rsrc);
-    return Response.ok("");
+    return Response.ok();
   }
 
   private boolean isReviewed(ChangeResource rsrc) {
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
index 5945b14..601fc4a 100644
--- a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
@@ -52,7 +52,7 @@
   @Override
   public Response<String> apply(ChangeResource rsrc, Input input) throws IllegalLabelException {
     stars.markAsUnreviewed(rsrc);
-    return Response.ok("");
+    return Response.ok();
   }
 
   private boolean isReviewed(ChangeResource rsrc) {
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index a57bd64..539463f 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -96,6 +96,7 @@
     post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
     post(CHANGE_KIND, "restore").to(Restore.class);
     post(CHANGE_KIND, "revert").to(Revert.class);
+    post(CHANGE_KIND, "revert_submission").to(RevertSubmission.class);
     post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
     get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
     post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index f008df3..c9ad049 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -39,7 +40,7 @@
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class PostPrivate extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, String>
+public class PostPrivate extends RetryingRestModifyView<ChangeResource, InputWithMessage, String>
     implements UiAction<ChangeResource> {
   private final PermissionBackend permissionBackend;
   private final SetPrivateOp.Factory setPrivateOpFactory;
@@ -59,7 +60,7 @@
 
   @Override
   public Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, InputWithMessage input)
       throws RestApiException, UpdateException {
     if (disablePrivateChanges) {
       throw new MethodNotAllowedException("private changes are disabled");
@@ -70,7 +71,7 @@
     }
 
     if (rsrc.getChange().isPrivate()) {
-      return Response.ok("");
+      return Response.ok();
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(true, input);
@@ -79,7 +80,7 @@
       u.addOp(rsrc.getId(), op).execute();
     }
 
-    return Response.created("");
+    return Response.created();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 974a72c..602ab19 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -249,6 +249,8 @@
     ProjectState projectState = projectCache.checkedGet(revision.getProject());
     LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes());
 
+    logger.atFine().log("strict label checking is %s", (strictLabels ? "enabled" : "disabled"));
+
     input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
     logger.atFine().log("draft handling = %s", input.drafts);
 
@@ -270,6 +272,7 @@
     if (input.notify == null) {
       input.notify = defaultNotify(revision.getChange(), input);
     }
+    logger.atFine().log("notify handling = %s", input.notify);
 
     Map<String, AddReviewerResult> reviewerJsonResults = null;
     List<ReviewerAddition> reviewerResults = Lists.newArrayList();
@@ -282,13 +285,18 @@
             reviewerAdder.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
         if (result.result.error != null) {
+          logger.atFine().log(
+              "Adding %s as reviewer failed: %s", reviewerInput.reviewer, result.result.error);
           hasError = true;
           continue;
         }
         if (result.result.confirm != null) {
+          logger.atFine().log(
+              "Adding %s as reviewer requires confirmation", reviewerInput.reviewer);
           confirm = true;
           continue;
         }
+        logger.atFine().log("Adding %s as reviewer was prepared", reviewerInput.reviewer);
         reviewerResults.add(result);
       }
     }
@@ -307,6 +315,9 @@
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
         ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
+        if (ccOrReviewer) {
+          logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label");
+        }
       }
 
       if (!ccOrReviewer) {
@@ -314,17 +325,22 @@
         ReviewerSet currentReviewers =
             approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
         ccOrReviewer = currentReviewers.all().contains(id);
+        if (ccOrReviewer) {
+          logger.atFine().log("calling user is already cc/reviewer on the change");
+        }
       }
 
       // Apply reviewer changes first. Revision emails should be sent to the
       // updated set of reviewers. Also keep track of whether the user added
       // themselves as a reviewer or to the CC list.
+      logger.atFine().log("adding reviewer additions");
       for (ReviewerAddition reviewerResult : reviewerResults) {
         reviewerResult.op.suppressEmail(); // Send a single batch email below.
         bu.addOp(revision.getChange().getId(), reviewerResult.op);
         if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
           for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
             if (Objects.equals(id.get(), reviewerInfo._accountId)) {
+              logger.atFine().log("calling user is explicitly added as reviewer");
               ccOrReviewer = true;
               break;
             }
@@ -333,6 +349,7 @@
         if (!ccOrReviewer && reviewerResult.result.ccs != null) {
           for (AccountInfo accountInfo : reviewerResult.result.ccs) {
             if (Objects.equals(id.get(), accountInfo._accountId)) {
+              logger.atFine().log("calling user is explicitly added as cc");
               ccOrReviewer = true;
               break;
             }
@@ -344,6 +361,7 @@
         // User posting this review isn't currently in the reviewer or CC list,
         // isn't being explicitly added, and isn't voting on any label.
         // Automatically CC them on this change so they receive replies.
+        logger.atFine().log("CCing calling user");
         ReviewerAddition selfAddition = reviewerAdder.ccCurrentUser(revision.getUser(), revision);
         selfAddition.op.suppressEmail();
         bu.addOp(revision.getChange().getId(), selfAddition.op);
@@ -365,6 +383,7 @@
           output.ready = true;
         }
 
+        logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
         WorkInProgressOp wipOp =
             workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
         wipOp.suppressEmail();
@@ -372,6 +391,7 @@
       }
 
       // Add the review op.
+      logger.atFine().log("posting review");
       bu.addOp(
           revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
 
@@ -455,6 +475,8 @@
   private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
       throws BadRequestException, AuthException, UnprocessableEntityException,
           PermissionBackendException, IOException, ConfigInvalidException {
+    logger.atFine().log("request is executed on behalf of %s", in.onBehalfOf);
+
     if (in.labels == null || in.labels.isEmpty()) {
       throw new AuthException(
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
@@ -463,6 +485,8 @@
       throw new AuthException("not allowed to modify other user's drafts");
     }
 
+    logger.atFine().log("label input: %s", in.labels);
+
     CurrentUser caller = rev.getUser();
     PermissionBackend.ForChange perm = rev.permissions();
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
@@ -470,15 +494,22 @@
       Map.Entry<String, Short> ent = itr.next();
       LabelType type = labelTypes.byLabel(ent.getKey());
       if (type == null) {
+        logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
               String.format("label \"%s\" is not a configured label", ent.getKey()));
         }
+        logger.atFine().log("ignoring input for unknown label %s", ent.getKey());
         itr.remove();
         continue;
       }
 
-      if (!caller.isInternalUser()) {
+      if (caller.isInternalUser()) {
+        logger.atFine().log(
+            "skipping on behalf of permission check for label %s"
+                + " because caller is an internal user",
+            type.getName());
+      } else {
         try {
           perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
         } catch (AuthException e) {
@@ -490,11 +521,13 @@
       }
     }
     if (in.labels.isEmpty()) {
+      logger.atFine().log("labels are empty after unknown labels have been removed");
       throw new AuthException(
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
     }
 
     IdentifiedUser reviewer = accountResolver.resolve(in.onBehalfOf).asUniqueUserOnBehalfOf(caller);
+    logger.atFine().log("on behalf of user was resolved to %s", reviewer.getLoggableName());
     try {
       permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
     } catch (AuthException e) {
@@ -508,16 +541,20 @@
 
   private void checkLabels(RevisionResource rsrc, LabelTypes labelTypes, Map<String, Short> labels)
       throws BadRequestException, AuthException, PermissionBackendException {
+    logger.atFine().log("checking label input: %s", labels);
+
     PermissionBackend.ForChange perm = rsrc.permissions();
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
       LabelType lt = labelTypes.byLabel(ent.getKey());
       if (lt == null) {
+        logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
               String.format("label \"%s\" is not a configured label", ent.getKey()));
         }
+        logger.atFine().log("ignoring input for unknown label %s", ent.getKey());
         itr.remove();
         continue;
       }
@@ -529,10 +566,13 @@
       }
 
       if (lt.getValue(ent.getValue()) == null) {
+        logger.atFine().log("label value %s not found", ent.getValue());
         if (strictLabels) {
           throw new BadRequestException(
               String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
         }
+        logger.atFine().log(
+            "ignoring input for label %s because label value is unknown", ent.getKey());
         itr.remove();
         continue;
       }
@@ -576,6 +616,7 @@
   private <T extends CommentInput> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
       throws BadRequestException, PatchListNotAvailableException {
+    logger.atFine().log("checking comments");
     Set<String> revisionFilePaths = getAffectedFilePaths(revision);
     for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
       String path = entry.getKey();
@@ -628,6 +669,7 @@
   private void checkRobotComments(
       RevisionResource revision, Map<String, List<RobotCommentInput>> in)
       throws BadRequestException, PatchListNotAvailableException {
+    logger.atFine().log("checking robot comments");
     for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
       String commentPath = e.getKey();
       for (RobotCommentInput c : e.getValue()) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index af8f971..50d1ad0 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -260,28 +260,23 @@
     return description;
   }
 
-  public static class CurrentRevision
-      extends RetryingRestModifyView<ChangeResource, RebaseInput, ChangeInfo> {
+  public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
 
     @Inject
-    CurrentRevision(RetryHelper retryHelper, PatchSetUtil psUtil, Rebase rebase) {
-      super(retryHelper);
+    CurrentRevision(PatchSetUtil psUtil, Rebase rebase) {
       this.psUtil = psUtil;
       this.rebase = rebase;
     }
 
     @Override
-    protected Response<ChangeInfo> applyImpl(
-        BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
-        throws Exception {
+    public Response<ChangeInfo> apply(ChangeResource rsrc, RebaseInput input) throws Exception {
       PatchSet ps = psUtil.current(rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       }
-      return Response.ok(
-          rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input).value());
+      return Response.ok(rebase.apply(new RevisionResource(rsrc, ps), input).value());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
new file mode 100644
index 0000000..105ffa2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.changes.RevertInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevertSubmissionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.lang.RandomStringUtils;
+
+@Singleton
+public class RevertSubmission
+    extends RetryingRestModifyView<ChangeResource, RevertInput, RevertSubmissionInfo>
+    implements UiAction<ChangeResource> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Revert revert;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
+  private final PatchSetUtil psUtil;
+  private final ContributorAgreementsChecker contributorAgreements;
+
+  @Inject
+  RevertSubmission(
+      RetryHelper retryHelper,
+      Revert revert,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeResource.Factory changeResourceFactory,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      PatchSetUtil psUtil,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
+    this.revert = revert;
+    this.queryProvider = queryProvider;
+    this.changeResourceFactory = changeResourceFactory;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
+    this.psUtil = psUtil;
+    this.contributorAgreements = contributorAgreements;
+  }
+
+  @Override
+  public Response<RevertSubmissionInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource changeResource, RevertInput input)
+      throws Exception {
+
+    if (!changeResource.getChange().isMerged()) {
+      throw new ResourceConflictException(
+          String.format("change is %s.", ChangeUtil.status(changeResource.getChange())));
+    }
+
+    String submissionId =
+        requireNonNull(
+            changeResource.getChange().getSubmissionId(),
+            String.format("merged change %s has no submission ID", changeResource.getId()));
+
+    List<ChangeData> changeDatas = queryProvider.get().bySubmissionId(submissionId);
+
+    for (ChangeData changeData : changeDatas) {
+      Change change = changeData.change();
+
+      // Might do the permission tests multiple times, but these are necessary to ensure that the
+      // user has permissions to revert all changes. If they lack any permission, no revert will be
+      // done.
+
+      contributorAgreements.check(change.getProject(), changeResource.getUser());
+      permissionBackend.currentUser().ref(change.getDest()).check(CREATE_CHANGE);
+      permissionBackend.currentUser().change(changeData).check(ChangePermission.READ);
+      projectCache.checkedGet(change.getProject()).checkStatePermitsWrite();
+
+      requireNonNull(
+          psUtil.get(changeData.notes(), change.currentPatchSetId()),
+          String.format(
+              "current patch set %s of change %s not found",
+              change.currentPatchSetId(), change.currentPatchSetId()));
+    }
+    return Response.ok(revertSubmission(changeDatas, input, submissionId));
+  }
+
+  private RevertSubmissionInfo revertSubmission(
+      List<ChangeData> changeDatas, RevertInput input, String submissionId) throws Exception {
+    List<ChangeInfo> results;
+    results = new ArrayList<>();
+    if (input.topic == null) {
+      input.topic =
+          String.format(
+              "revert-%s-%s", submissionId, RandomStringUtils.randomAlphabetic(10).toUpperCase());
+    }
+    for (ChangeData changeData : changeDatas) {
+      ChangeResource change = changeResourceFactory.create(changeData.notes(), user.get());
+      // Reverts are done with retrying by using RetryingRestModifyView.
+      results.add(revert.apply(change, input).value());
+    }
+    RevertSubmissionInfo revertSubmissionInfo = new RevertSubmissionInfo();
+    revertSubmissionInfo.revertChanges = results;
+    return revertSubmissionInfo;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    boolean projectStatePermitsWrite = false;
+    try {
+      projectStatePermitsWrite = projectCache.checkedGet(rsrc.getProject()).statePermitsWrite();
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(
+          "Failed to check if project state permits write: %s", rsrc.getProject());
+    }
+    return new UiAction.Description()
+        .setLabel("Revert submission")
+        .setTitle(
+            "Revert this change and all changes that have been submitted together with this change")
+        .setVisible(
+            and(
+                change.isMerged() && projectStatePermitsWrite,
+                permissionBackend
+                    .user(rsrc.getUser())
+                    .ref(change.getDest())
+                    .testCond(CREATE_CHANGE)));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewed.java b/java/com/google/gerrit/server/restapi/change/Reviewed.java
index 7152799..2793059 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewed.java
@@ -43,7 +43,7 @@
                       resource.getPatchKey().patchSetId(),
                       resource.getAccountId(),
                       resource.getPatchKey().fileName()));
-      return reviewFlagUpdated ? Response.created("") : Response.ok("");
+      return reviewFlagUpdated ? Response.created() : Response.ok();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 288806c..8470742 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -69,7 +69,7 @@
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
       bu.execute();
-      return Response.ok("");
+      return Response.ok();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index 3fb0295..60884c9 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -69,7 +69,7 @@
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
       bu.execute();
-      return Response.ok("");
+      return Response.ok();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Unignore.java b/java/com/google/gerrit/server/restapi/change/Unignore.java
index 26d3233..999e736 100644
--- a/java/com/google/gerrit/server/restapi/change/Unignore.java
+++ b/java/com/google/gerrit/server/restapi/change/Unignore.java
@@ -50,7 +50,7 @@
     if (isIgnored(rsrc)) {
       stars.unignore(rsrc);
     }
-    return Response.ok("");
+    return Response.ok();
   }
 
   private boolean isIgnored(ChangeResource rsrc) {
diff --git a/java/com/google/gerrit/server/restapi/config/FlushCache.java b/java/com/google/gerrit/server/restapi/config/FlushCache.java
index 9ea9e33..f10ed8d 100644
--- a/java/com/google/gerrit/server/restapi/config/FlushCache.java
+++ b/java/com/google/gerrit/server/restapi/config/FlushCache.java
@@ -50,6 +50,6 @@
     }
 
     rsrc.getCache().invalidateAll();
-    return Response.ok("");
+    return Response.ok();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java
index c633af0..c9480c5 100644
--- a/java/com/google/gerrit/server/restapi/config/PostCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -84,13 +84,13 @@
               "specifying caches is not allowed for operation 'FLUSH_ALL'");
         }
         flushAll();
-        return Response.ok("");
+        return Response.ok();
       case FLUSH:
         if (input.caches == null || input.caches.isEmpty()) {
           throw new BadRequestException("caches must be specified for operation 'FLUSH'");
         }
         flush(input.caches);
-        return Response.ok("");
+        return Response.ok();
       default:
         throw new BadRequestException("unsupported operation: " + input.operation);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 334447b..7c88ab3 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -26,6 +26,7 @@
 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.index.query.Predicate;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.project.CommitResource;
@@ -33,7 +34,9 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.Reachable;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.CommitPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.ProjectPredicate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryHelper.Action;
 import com.google.gerrit.server.update.RetryHelper.ActionType;
@@ -41,7 +44,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -116,12 +123,13 @@
   public boolean canRead(ProjectState state, Repository repo, RevCommit commit) throws IOException {
     Project.NameKey project = state.getNameKey();
     if (indexes.getSearchIndex() == null) {
-      // No index in slaves, fall back to scanning refs.
+      // No index in slaves, fall back to scanning refs. We must inspect change refs too
+      // as the commit might be a patchset of a not yet submitted change.
       return reachable.fromRefs(project, repo, commit, repo.getRefDatabase().getRefs());
     }
 
-    // Check first if any change references the commit in question. This is much cheaper than ref
-    // visibility filtering and reachability computation.
+    // Check first if any patchset of any change references the commit in question. This is much
+    // cheaper than ref visibility filtering and reachability computation.
     List<ChangeData> changes =
         executeIndexQuery(
             () ->
@@ -134,6 +142,30 @@
       return true;
     }
 
+    // Maybe the commit was a merge commit of a change. Try to find promising candidates for
+    // branches to check, by seeing if its parents were associated to changes.
+    Predicate<ChangeData> pred =
+        Predicate.and(
+            new ProjectPredicate(project.get()),
+            Predicate.or(
+                Arrays.stream(commit.getParents())
+                    .map(parent -> new CommitPredicate(parent.getId().getName()))
+                    .collect(toImmutableList())));
+    changes = executeIndexQuery(() -> queryProvider.get().enforceVisibility(true).query(pred));
+
+    Set<Ref> branchesForCommitParents = new HashSet<>(changes.size());
+    for (ChangeData cd : changes) {
+      Ref ref = repo.exactRef(cd.change().getDest().branch());
+      if (ref != null) {
+        branchesForCommitParents.add(ref);
+      }
+    }
+
+    if (reachable.fromRefs(
+        project, repo, commit, branchesForCommitParents.stream().collect(Collectors.toList()))) {
+      return true;
+    }
+
     // If we have already checked change refs using the change index, spare any further checks for
     // changes.
     List<Ref> refs =
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
new file mode 100644
index 0000000..03b9452
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -0,0 +1,188 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.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.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelDefinitionJson;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CreateLabel
+    implements RestCollectionCreateView<ProjectResource, LabelResource, LabelDefinitionInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public CreateLabel(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<LabelDefinitionInfo> apply(
+      ProjectResource rsrc, IdString id, LabelDefinitionInput input)
+      throws AuthException, BadRequestException, ResourceConflictException,
+          PermissionBackendException, IOException, ConfigInvalidException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new LabelDefinitionInput();
+    }
+
+    if (input.name != null && !input.name.equals(id.get())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (config.getLabelSections().containsKey(id.get())) {
+        throw new ResourceConflictException(String.format("label %s already exists", id.get()));
+      }
+
+      for (String labelName : config.getLabelSections().keySet()) {
+        if (labelName.equalsIgnoreCase(id.get())) {
+          throw new ResourceConflictException(
+              String.format("label %s conflicts with existing label %s", id.get(), labelName));
+        }
+      }
+
+      if (input.values == null || input.values.isEmpty()) {
+        throw new BadRequestException("values are required");
+      }
+
+      List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
+
+      LabelType labelType;
+      try {
+        labelType = new LabelType(id.get(), values);
+      } catch (IllegalArgumentException e) {
+        throw new BadRequestException("invalid name: " + id.get(), e);
+      }
+
+      if (input.function != null && !input.function.trim().isEmpty()) {
+        labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+      } else {
+        labelType.setFunction(LabelFunction.MAX_WITH_BLOCK);
+      }
+
+      if (input.defaultValue != null) {
+        labelType.setDefaultValue(
+            LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+      }
+
+      if (input.branches != null) {
+        labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+      }
+
+      if (input.canOverride != null) {
+        labelType.setCanOverride(input.canOverride);
+      }
+
+      if (input.copyAnyScore != null) {
+        labelType.setCopyAnyScore(input.copyAnyScore);
+      }
+
+      if (input.copyMinScore != null) {
+        labelType.setCopyMinScore(input.copyMinScore);
+      }
+
+      if (input.copyMaxScore != null) {
+        labelType.setCopyMaxScore(input.copyMaxScore);
+      }
+
+      if (input.copyAllScoresIfNoChange != null) {
+        labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+      }
+
+      if (input.copyAllScoresIfNoCodeChange != null) {
+        labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+      }
+
+      if (input.copyAllScoresOnTrivialRebase != null) {
+        labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+      }
+
+      if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
+        labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+            input.copyAllScoresOnMergeFirstParentUpdate);
+      }
+
+      if (input.allowPostSubmit != null) {
+        labelType.setAllowPostSubmit(input.allowPostSubmit);
+      }
+
+      if (input.ignoreSelfApproval != null) {
+        labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+      }
+
+      if (input.commitMessage != null) {
+        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+      } else {
+        md.setMessage("Update label");
+      }
+
+      config.getLabelSections().put(labelType.getName(), labelType);
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProjectState().getProject());
+
+      return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
new file mode 100644
index 0000000..5464abf
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.InputWithCommitMessage;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteLabel implements RestModifyView<LabelResource, InputWithCommitMessage> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public DeleteLabel(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<?> apply(LabelResource rsrc, InputWithCommitMessage input)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException,
+          ConfigInvalidException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new InputWithCommitMessage();
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (!config.getLabelSections().containsKey(rsrc.getLabelType().getName())) {
+        throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getLabelType().getName()));
+      }
+
+      config.getLabelSections().remove(rsrc.getLabelType().getName());
+
+      if (input.commitMessage != null) {
+        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+      } else {
+        md.setMessage("Delete label");
+      }
+
+      config.commit(md);
+    }
+
+    projectCache.evict(rsrc.getProject().getProjectState().getProject());
+
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetLabel.java b/java/com/google/gerrit/server/restapi/project/GetLabel.java
new file mode 100644
index 0000000..626cb42
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetLabel.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.server.restapi.project;
+
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+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.project.LabelDefinitionJson;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetLabel implements RestReadView<LabelResource> {
+  @Override
+  public Response<LabelDefinitionInfo> apply(LabelResource rsrc)
+      throws AuthException, BadRequestException {
+    return Response.ok(
+        LabelDefinitionJson.format(rsrc.getProject().getNameKey(), rsrc.getLabelType()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
new file mode 100644
index 0000000..a45c67f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.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.restapi.project;
+
+import com.google.common.primitives.Shorts;
+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.PermissionRule;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.project.RefPattern;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+
+public class LabelDefinitionInputParser {
+  public static LabelFunction parseFunction(String functionString) throws BadRequestException {
+    Optional<LabelFunction> function = LabelFunction.parse(functionString.trim());
+    return function.orElseThrow(
+        () -> new BadRequestException("unknown function: " + functionString));
+  }
+
+  public static List<LabelValue> parseValues(Map<String, String> values)
+      throws BadRequestException {
+    List<LabelValue> valueList = new ArrayList<>();
+    for (Entry<String, String> e : values.entrySet()) {
+      short value;
+      try {
+        value = Shorts.checkedCast(PermissionRule.parseInt(e.getKey().trim()));
+      } catch (NumberFormatException ex) {
+        throw new BadRequestException("invalid value: " + e.getKey(), ex);
+      }
+      String valueDescription = e.getValue().trim();
+      if (valueDescription.isEmpty()) {
+        throw new BadRequestException("description for value '" + e.getKey() + "' cannot be empty");
+      }
+      valueList.add(new LabelValue(value, valueDescription));
+    }
+    return valueList;
+  }
+
+  public static short parseDefaultValue(LabelType labelType, short defaultValue)
+      throws BadRequestException {
+    if (labelType.getValue(defaultValue) == null) {
+      throw new BadRequestException("invalid default value: " + defaultValue);
+    }
+    return defaultValue;
+  }
+
+  public static List<String> parseBranches(List<String> branches) throws BadRequestException {
+    List<String> validBranches = new ArrayList<>();
+    for (String branch : branches) {
+      String newBranch = branch.trim();
+      if (newBranch.isEmpty()) {
+        continue;
+      }
+      if (!RefPattern.isRE(newBranch) && !newBranch.startsWith(RefNames.REFS)) {
+        newBranch = RefNames.REFS_HEADS + newBranch;
+      }
+      try {
+        RefPattern.validate(newBranch);
+      } catch (InvalidNameException e) {
+        throw new BadRequestException("invalid branch: " + branch, e);
+      }
+      validBranches.add(newBranch);
+    }
+    return validBranches;
+  }
+
+  private LabelDefinitionInputParser() {}
+}
diff --git a/java/com/google/gerrit/server/restapi/project/LabelsCollection.java b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
new file mode 100644
index 0000000..0409729
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.common.data.LabelType;
+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.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class LabelsCollection implements ChildCollection<ProjectResource, LabelResource> {
+  private final Provider<ListLabels> list;
+  private final DynamicMap<RestView<LabelResource>> views;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  LabelsCollection(
+      Provider<ListLabels> list,
+      DynamicMap<RestView<LabelResource>> views,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend) {
+    this.list = list;
+    this.views = views;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public LabelResource parse(ProjectResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(parent.getNameKey())
+        .check(ProjectPermission.READ_CONFIG);
+    LabelType labelType = parent.getProjectState().getConfig().getLabelSections().get(id.get());
+    if (labelType == null) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new LabelResource(parent, labelType);
+  }
+
+  @Override
+  public DynamicMap<RestView<LabelResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListLabels.java b/java/com/google/gerrit/server/restapi/project/ListLabels.java
new file mode 100644
index 0000000..19a8915
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListLabels.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.restapi.project;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+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.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelDefinitionJson;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+public class ListLabels implements RestReadView<ProjectResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  public ListLabels(Provider<CurrentUser> user, PermissionBackend permissionBackend) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Option(name = "--inherited", usage = "to include inherited label definitions")
+  private boolean inherited;
+
+  public ListLabels withInherited(boolean inherited) {
+    this.inherited = inherited;
+    return this;
+  }
+
+  @Override
+  public Response<List<LabelDefinitionInfo>> apply(ProjectResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (inherited) {
+      List<LabelDefinitionInfo> allLabels = new ArrayList<>();
+      for (ProjectState projectState : rsrc.getProjectState().treeInOrder()) {
+        try {
+          permissionBackend
+              .currentUser()
+              .project(projectState.getNameKey())
+              .check(ProjectPermission.READ_CONFIG);
+        } catch (AuthException e) {
+          throw new AuthException(projectState.getNameKey() + ": " + e.getMessage(), e);
+        }
+        allLabels.addAll(listLabels(projectState));
+      }
+      return Response.ok(allLabels);
+    }
+
+    permissionBackend.currentUser().project(rsrc.getNameKey()).check(ProjectPermission.READ_CONFIG);
+    return Response.ok(listLabels(rsrc.getProjectState()));
+  }
+
+  private List<LabelDefinitionInfo> listLabels(ProjectState projectState) {
+    Collection<LabelType> labelTypes = projectState.getConfig().getLabelSections().values();
+    List<LabelDefinitionInfo> labels = new ArrayList<>(labelTypes.size());
+    for (LabelType labelType : labelTypes) {
+      labels.add(LabelDefinitionJson.format(projectState.getNameKey(), labelType));
+    }
+    labels.sort(Comparator.comparing(l -> l.name));
+    return labels;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index de5661d..2c76cbd 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.project.CommitResource.COMMIT_KIND;
 import static com.google.gerrit.server.project.DashboardResource.DASHBOARD_KIND;
 import static com.google.gerrit.server.project.FileResource.FILE_KIND;
+import static com.google.gerrit.server.project.LabelResource.LABEL_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
@@ -42,6 +43,7 @@
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
+    DynamicMap.mapOf(binder(), LABEL_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
 
@@ -65,6 +67,12 @@
     child(PROJECT_KIND, "children").to(ChildProjectsCollection.class);
     get(CHILD_PROJECT_KIND).to(GetChildProject.class);
 
+    child(PROJECT_KIND, "labels").to(LabelsCollection.class);
+    create(LABEL_KIND).to(CreateLabel.class);
+    get(LABEL_KIND).to(GetLabel.class);
+    put(LABEL_KIND).to(SetLabel.class);
+    delete(LABEL_KIND).to(DeleteLabel.class);
+
     get(PROJECT_KIND, "HEAD").to(GetHead.class);
     put(PROJECT_KIND, "HEAD").to(SetHead.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 696ac37..a0badd7 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -14,11 +14,17 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.server.project.ProjectConfig.COMMENTLINK;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_ENABLED;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_LINK;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_MATCH;
+
 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.CommentLinkInput;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
@@ -59,6 +65,7 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class PutConfig implements RestModifyView<ProjectResource, ConfigInput> {
@@ -154,6 +161,10 @@
         setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
       }
 
+      if (input.commentLinks != null) {
+        updateCommentLinks(projectConfig, input.commentLinks);
+      }
+
       md.setMessage("Modified project settings\n");
       try {
         projectConfig.commit(md);
@@ -278,6 +289,25 @@
     }
   }
 
+  private void updateCommentLinks(
+      ProjectConfig projectConfig, Map<String, CommentLinkInput> input) {
+    for (Map.Entry<String, CommentLinkInput> e : input.entrySet()) {
+      String name = e.getKey();
+      CommentLinkInput value = e.getValue();
+      if (value != null) {
+        // Add or update the commentlink section
+        Config cfg = new Config();
+        cfg.setString(COMMENTLINK, name, KEY_MATCH, value.match);
+        cfg.setString(COMMENTLINK, name, KEY_LINK, value.link);
+        cfg.setBoolean(COMMENTLINK, name, KEY_ENABLED, value.enabled == null || value.enabled);
+        projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name, false));
+      } else {
+        // Delete the commentlink section
+        projectConfig.removeCommentLinkSection(name);
+      }
+    }
+  }
+
   private static void validateProjectConfigEntryIsEditable(
       ProjectConfigEntry projectConfigEntry,
       ProjectState projectState,
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
new file mode 100644
index 0000000..b7cffce
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -0,0 +1,206 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelDefinitionJson;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class SetLabel implements RestModifyView<LabelResource, LabelDefinitionInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public SetLabel(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<LabelDefinitionInfo> apply(LabelResource rsrc, LabelDefinitionInput input)
+      throws AuthException, BadRequestException, ResourceConflictException,
+          PermissionBackendException, IOException, ConfigInvalidException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new LabelDefinitionInput();
+    }
+
+    LabelType labelType = rsrc.getLabelType();
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      boolean dirty = false;
+
+      ProjectConfig config = projectConfigFactory.read(md);
+      config.getLabelSections().remove(labelType.getName());
+
+      if (input.name != null) {
+        String newName = input.name.trim();
+        if (newName.isEmpty()) {
+          throw new BadRequestException("name cannot be empty");
+        }
+        if (!newName.equals(labelType.getName())) {
+          if (config.getLabelSections().containsKey(newName)) {
+            throw new ResourceConflictException(String.format("name %s already in use", newName));
+          }
+
+          for (String labelName : config.getLabelSections().keySet()) {
+            if (labelName.equalsIgnoreCase(newName)) {
+              throw new ResourceConflictException(
+                  String.format("name %s conflicts with existing label %s", newName, labelName));
+            }
+          }
+
+          try {
+            labelType.setName(newName);
+          } catch (IllegalArgumentException e) {
+            throw new BadRequestException("invalid name: " + input.name, e);
+          }
+          dirty = true;
+        }
+      }
+
+      if (input.function != null) {
+        if (input.function.trim().isEmpty()) {
+          throw new BadRequestException("function cannot be empty");
+        }
+        labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+        dirty = true;
+      }
+
+      if (input.values != null) {
+        if (input.values.isEmpty()) {
+          throw new BadRequestException("values cannot be empty");
+        }
+        labelType.setValues(LabelDefinitionInputParser.parseValues(input.values));
+        dirty = true;
+      }
+
+      if (input.defaultValue != null) {
+        labelType.setDefaultValue(
+            LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+        dirty = true;
+      }
+
+      if (input.branches != null) {
+        labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+        dirty = true;
+      }
+
+      if (input.canOverride != null) {
+        labelType.setCanOverride(input.canOverride);
+        dirty = true;
+      }
+
+      if (input.copyAnyScore != null) {
+        labelType.setCopyAnyScore(input.copyAnyScore);
+        dirty = true;
+      }
+
+      if (input.copyMinScore != null) {
+        labelType.setCopyMinScore(input.copyMinScore);
+        dirty = true;
+      }
+
+      if (input.copyMaxScore != null) {
+        labelType.setCopyMaxScore(input.copyMaxScore);
+        dirty = true;
+      }
+
+      if (input.copyAllScoresIfNoChange != null) {
+        labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+      }
+
+      if (input.copyAllScoresIfNoCodeChange != null) {
+        labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+        dirty = true;
+      }
+
+      if (input.copyAllScoresOnTrivialRebase != null) {
+        labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+        dirty = true;
+      }
+
+      if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
+        labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+            input.copyAllScoresOnMergeFirstParentUpdate);
+        dirty = true;
+      }
+
+      if (input.allowPostSubmit != null) {
+        labelType.setAllowPostSubmit(input.allowPostSubmit);
+        dirty = true;
+      }
+
+      if (input.ignoreSelfApproval != null) {
+        labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+        dirty = true;
+      }
+
+      if (dirty) {
+        config.getLabelSections().put(labelType.getName(), labelType);
+
+        if (input.commitMessage != null) {
+          md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+        } else {
+          md.setMessage("Update label");
+        }
+
+        config.commit(md);
+        projectCache.evict(rsrc.getProject().getProjectState().getProject());
+      }
+    }
+    return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index a06027a..406c0a1 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -504,7 +504,7 @@
               try {
                 integrateIntoHistory(cs);
               } catch (IntegrationException e) {
-                logger.atSevere().withCause(e).log("Error from integrateIntoHistory");
+                logger.atWarning().withCause(e).log("Error from integrateIntoHistory");
                 throw new ResourceConflictException(e.getMessage(), e);
               }
               return null;
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index c2577e7..9a7ced5 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -42,10 +42,9 @@
 import java.util.Map;
 import java.util.Objects;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -130,19 +129,17 @@
   }
 
   public static class OpenBranch {
-    final RefUpdate update;
     final CodeReviewCommit oldTip;
     MergeTip mergeTip;
 
     OpenBranch(OpenRepo or, BranchNameKey name) throws IntegrationException {
       try {
-        update = or.repo.updateRef(name.branch());
-        if (update.getOldObjectId() != null) {
-          oldTip = or.rw.parseCommit(update.getOldObjectId());
+        Ref ref = or.getRepo().exactRef(name.branch());
+        if (ref != null) {
+          oldTip = or.rw.parseCommit(ref.getObjectId());
         } else if (Objects.equals(or.repo.getFullBranch(), name.branch())
             || Objects.equals(RefNames.REFS_CONFIG, name.branch())) {
           oldTip = null;
-          update.setExpectedOldObjectId(ObjectId.zeroId());
         } else {
           throw new IntegrationException(
               "The destination branch " + name + " does not exist anymore.");
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index ba12a12..4ba6475 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -3010,7 +3010,7 @@
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
     Account.Id accountId = Account.id(accountInfo._accountId);
-    assertThat(stalenessChecker.isStale(accountId)).isFalse();
+    assertThat(stalenessChecker.check(accountId).isStale()).isFalse();
 
     // Manually updating the user ref makes the index document stale.
     String userRef = RefNames.refsUsers(accountId);
@@ -3078,11 +3078,11 @@
     // has to happen directly on the accounts cache because AccountCacheImpl triggers a reindex for
     // the account.
     accountsCache.invalidate(accountId);
-    assertThat(stalenessChecker.isStale(accountId)).isTrue();
+    assertThat(stalenessChecker.check(accountId).isStale()).isTrue();
 
     // Reindex fixes staleness
     accountIndexer.index(accountId);
-    assertThat(stalenessChecker.isStale(accountId)).isFalse();
+    assertThat(stalenessChecker.check(accountId).isStale()).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 5550d98..9059cb1 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -239,6 +239,28 @@
   }
 
   @Test
+  public void revertSubmissionWithoutCLA() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
+
+    // Approve and submit it
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
+
+    // Revert Submission is not allowed when CLA is required but not signed
+    requestScopeOperations.setApiUser(user.id());
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(change.changeId).revertSubmission());
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
+  }
+
+  @Test
   public void revertExcludedProjectChangeWithoutCLA() throws Exception {
     // Contributor agreements configured with excludeProjects = ExcludedProject
     // in AbstractDaemonTest.configureContributorAgreement(...)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
index 92f914b..d73bab0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -97,6 +98,31 @@
     assertThat(result2.get(1).get(0)._moreChanges).isTrue();
   }
 
+  @Test
+  @SuppressWarnings("unchecked")
+  @GerritConfig(name = "operator-alias.change.numberaliastest", value = "change")
+  public void aliasQuery() throws Exception {
+    String cId1 = createChange().getChangeId();
+    String cId2 = createChange().getChangeId();
+    int numericId1 = gApi.changes().id(cId1).get()._number;
+    int numericId2 = gApi.changes().id(cId2).get()._number;
+
+    QueryChanges queryChanges = queryChangesProvider.get();
+    queryChanges.addQuery("numberaliastest:12345");
+    queryChanges.addQuery("numberaliastest:" + numericId1);
+    queryChanges.addQuery("numberaliastest:" + numericId2);
+
+    List<List<ChangeInfo>> result =
+        (List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE).value();
+    assertThat(result).hasSize(3);
+    assertThat(result.get(0)).hasSize(0);
+    assertThat(result.get(1)).hasSize(1);
+    assertThat(result.get(2)).hasSize(1);
+
+    assertThat(result.get(1).get(0)._number).isEqualTo(numericId1);
+    assertThat(result.get(2).get(0)._number).isEqualTo(numericId2);
+  }
+
   private static void assertNoChangeHasMoreChangesSet(List<ChangeInfo> results) {
     for (ChangeInfo info : results) {
       assertThat(info._moreChanges).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 0607a3c..0e89de2 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -21,12 +21,16 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 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.Permission;
+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.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -36,6 +40,8 @@
 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.common.RevertSubmissionInfo;
+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;
@@ -45,8 +51,10 @@
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -361,17 +369,20 @@
 
   @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();
+    PushOneCommit.Result result = createChange();
+    gApi.changes()
+        .id(result.getChangeId())
+        .revision(result.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
     projectCache.checkedGet(project).getProject().setState(ProjectState.READ_ONLY);
 
+    String expected = "project state " + ProjectState.READ_ONLY + " does not permit write";
     ResourceConflictException thrown =
         assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(r.getChangeId()).revert());
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("project state " + ProjectState.READ_ONLY + " does not permit write");
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result.getChangeId()).revert());
+    assertThat(thrown).hasMessageThat().contains(expected);
   }
 
   @Test
@@ -412,6 +423,339 @@
     assertThat(thrown).hasMessageThat().contains("Not found: " + r.getChangeId());
   }
 
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantCreateRevertSubmissionWithoutProjectWritePermission() throws Exception {
+    String secondProject = "secondProject";
+    projectOperations.newProject().name(secondProject).create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    String topic = "topic";
+    PushOneCommit.Result result1 =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic);
+    PushOneCommit.Result result2 =
+        createChange(secondRepo, "master", "second change", "b.txt", "message", topic);
+    gApi.changes().id(result1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result2.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result1.getChangeId()).revision(result1.getCommit().name()).submit();
+
+    // revoke write permissions for the first repository.
+    projectCache.checkedGet(project).getProject().setState(ProjectState.READ_ONLY);
+
+    String expected = "project state " + ProjectState.READ_ONLY + " does not permit write";
+
+    // assert that if first repository has no write permissions, it will fail.
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result1.getChangeId()).revertSubmission());
+    assertThat(thrown).hasMessageThat().contains(expected);
+
+    // assert that if the first repository has no write permissions and a change from another
+    // repository is trying to revert the submission, it will fail.
+    thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result2.getChangeId()).revertSubmission());
+    assertThat(thrown).hasMessageThat().contains(expected);
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantCreateRevertSubmissionWithoutCreateChangePermission() throws Exception {
+    String secondProject = "secondProject";
+    projectOperations.newProject().name(secondProject).create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    String topic = "topic";
+    PushOneCommit.Result result1 =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic);
+    PushOneCommit.Result result2 =
+        createChange(secondRepo, "master", "second change", "b.txt", "message", topic);
+    gApi.changes().id(result1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result2.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result1.getChangeId()).revision(result1.getCommit().name()).submit();
+
+    // revoke create change permissions for the first repository.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
+
+    // assert that if first repository has no write create change, it will fail.
+    PermissionDeniedException thrown =
+        assertThrows(
+            PermissionDeniedException.class,
+            () -> gApi.changes().id(result1.getChangeId()).revertSubmission());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("not permitted: create change on refs/heads/master");
+
+    // assert that if the first repository has no create change permissions and a change from
+    // another repository is trying to revert the submission, it will fail.
+    thrown =
+        assertThrows(
+            PermissionDeniedException.class,
+            () -> gApi.changes().id(result2.getChangeId()).revertSubmission());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("not permitted: create change on refs/heads/master");
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantCreateRevertSubmissionWithoutReadPermission() throws Exception {
+    String secondProject = "secondProject";
+    projectOperations.newProject().name(secondProject).create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    String topic = "topic";
+    PushOneCommit.Result result1 =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic);
+    PushOneCommit.Result result2 =
+        createChange(secondRepo, "master", "second change", "b.txt", "message", topic);
+    gApi.changes().id(result1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result2.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result1.getChangeId()).revision(result1.getCommit().name()).submit();
+
+    // revoke read permissions for the first repository.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // assert that if first repository has no read permissions, it will fail.
+    ResourceNotFoundException resourceNotFoundException =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(result1.getChangeId()).revertSubmission());
+    assertThat(resourceNotFoundException)
+        .hasMessageThat()
+        .isEqualTo("Not found: " + result1.getChangeId());
+
+    // assert that if the first repository has no READ permissions and a change from another
+    // repository is trying to revert the submission, it will fail.
+    AuthException authException =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(result2.getChangeId()).revertSubmission());
+    assertThat(authException).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void revertSubmissionPreservesReviewersAndCcs() throws Exception {
+    PushOneCommit.Result r = createChange("first change", "a.txt", "message");
+
+    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 =
+        getChangeApis(gApi.changes().id(r.getChangeId()).revertSubmission()).get(0).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
+  public void revertSubmissionNotifications() throws Exception {
+    PushOneCommit.Result firstResult = createChange("first change", "a.txt", "message");
+    approve(firstResult.getChangeId());
+    gApi.changes().id(firstResult.getChangeId()).addReviewer(user.email());
+    PushOneCommit.Result secondResult = createChange("second change", "b.txt", "other");
+    approve(secondResult.getChangeId());
+    gApi.changes().id(secondResult.getChangeId()).addReviewer(user.email());
+
+    gApi.changes()
+        .id(secondResult.getChangeId())
+        .revision(secondResult.getCommit().name())
+        .submit();
+
+    sender.clear();
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.ALL;
+
+    RevertSubmissionInfo revertChanges =
+        gApi.changes().id(secondResult.getChangeId()).revertSubmission(revertInput);
+
+    List<Message> messages = sender.getMessages();
+
+    assertThat(messages).hasSize(4);
+    assertThat(sender.getMessages(revertChanges.revertChanges.get(0).changeId, "newchange"))
+        .hasSize(1);
+    assertThat(sender.getMessages(firstResult.getChangeId(), "revert")).hasSize(1);
+    assertThat(sender.getMessages(revertChanges.revertChanges.get(1).changeId, "newchange"))
+        .hasSize(1);
+    assertThat(sender.getMessages(secondResult.getChangeId(), "revert")).hasSize(1);
+  }
+
+  @Test
+  public void suppressRevertSubmissionNotifications() throws Exception {
+    PushOneCommit.Result firstResult = createChange("first change", "a.txt", "message");
+    approve(firstResult.getChangeId());
+    gApi.changes().id(firstResult.getChangeId()).addReviewer(user.email());
+    PushOneCommit.Result secondResult = createChange("second change", "b.txt", "other");
+    approve(secondResult.getChangeId());
+    gApi.changes().id(secondResult.getChangeId()).addReviewer(user.email());
+
+    gApi.changes()
+        .id(secondResult.getChangeId())
+        .revision(secondResult.getCommit().name())
+        .submit();
+
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.NONE;
+
+    sender.clear();
+    gApi.changes().id(secondResult.getChangeId()).revertSubmission(revertInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void revertSubmissionOfSingleChange() throws Exception {
+    PushOneCommit.Result result = createChange("Change", "a.txt", "message");
+    approve(result.getChangeId());
+    gApi.changes().id(result.getChangeId()).current().submit();
+    List<ChangeApi> revertChanges =
+        getChangeApis(gApi.changes().id(result.getChangeId()).revertSubmission());
+
+    String sha1Commit = result.getCommit().getName();
+
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1Commit);
+
+    assertThat(revertChanges.get(0).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+
+    assertThat(revertChanges.get(0).get().revertOf)
+        .isEqualTo(result.getChange().change().getChangeId());
+    assertThat(revertChanges.get(0).get().topic)
+        .startsWith("revert-" + result.getChange().change().getSubmissionId() + "-");
+  }
+
+  @Test
+  public void revertSubmissionWithSetTopic() 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())
+                .revertSubmission(revertInput)
+                .revertChanges
+                .get(0)
+                .topic)
+        .isEqualTo(revertInput.topic);
+  }
+
+  @Test
+  public void revertSubmissionWithSetMessage() 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())
+                .revertSubmission(revertInput)
+                .revertChanges
+                .get(0)
+                .subject)
+        .isEqualTo(revertInput.message);
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  @UseClockStep
+  public void revertSubmissionDifferentRepositoriesWithDependantChange() throws Exception {
+    projectOperations.newProject().name("secondProject").create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    List<PushOneCommit.Result> resultCommits = new ArrayList<>();
+    String topic = "topic";
+    resultCommits.add(createChange(testRepo, "master", "first change", "a.txt", "message", topic));
+    resultCommits.add(
+        createChange(secondRepo, "master", "first change", "a.txt", "message", topic));
+    resultCommits.add(
+        createChange(secondRepo, "master", "second change", "b.txt", "Other message", topic));
+    for (PushOneCommit.Result result : resultCommits) {
+      approve(result.getChangeId());
+    }
+    // submit all changes
+    gApi.changes().id(resultCommits.get(1).getChangeId()).current().submit();
+    List<ChangeApi> revertChanges =
+        getChangeApis(gApi.changes().id(resultCommits.get(1).getChangeId()).revertSubmission());
+
+    // The reverts are by update time, so the reversal ensures that
+    // revertChanges[i] is the revert of resultCommits[i]
+    Collections.reverse(revertChanges);
+
+    assertThat(revertChanges.get(0).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(2).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+    // has size 3 because of the same topic, and submitWholeTopic is true.
+    assertThat(gApi.changes().id(revertChanges.get(0).get()._number).submittedTogether())
+        .hasSize(3);
+
+    // 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. Created a revert of this change as %s
+
+    for (int i = 0; i < resultCommits.size(); i++) {
+      assertThat(revertChanges.get(i).current().commit(false).parents.get(0).commit)
+          .isEqualTo(resultCommits.get(i).getCommit().getName());
+      assertThat(revertChanges.get(i).get().revertOf)
+          .isEqualTo(resultCommits.get(i).getChange().change().getChangeId());
+      List<ChangeMessageInfo> sourceMessages =
+          new ArrayList<>(gApi.changes().id(resultCommits.get(i).getChangeId()).get().messages);
+      assertThat(sourceMessages).hasSize(4);
+      String expectedMessage =
+          String.format(
+              "Created a revert of this change as %s", revertChanges.get(i).get().changeId);
+      assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
+      // Expected message on the created change: "Uploaded patch set 1."
+      List<ChangeMessageInfo> messages =
+          revertChanges.get(i).get().messages.stream().collect(toList());
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).message).isEqualTo("Uploaded patch set 1.");
+      assertThat(revertChanges.get(i).get().revertOf)
+          .isEqualTo(gApi.changes().id(resultCommits.get(i).getChangeId()).get()._number);
+      assertThat(revertChanges.get(i).get().topic)
+          .startsWith("revert-" + resultCommits.get(0).getChange().change().getSubmissionId());
+    }
+  }
+
+  @Test
+  public void cantRevertSubmissionWithAnOpenChange() throws Exception {
+    PushOneCommit.Result result = createChange("first change", "a.txt", "message");
+    approve(result.getChangeId());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result.getChangeId()).revertSubmission());
+    assertThat(thrown).hasMessageThat().isEqualTo("change is new.");
+  }
+
   @Override
   protected PushOneCommit.Result createChange() throws Exception {
     return createChange("refs/for/master");
@@ -451,4 +795,13 @@
           .create();
     }
   }
+
+  private List<ChangeApi> getChangeApis(RevertSubmissionInfo revertSubmissionInfo)
+      throws Exception {
+    List<ChangeApi> results = new ArrayList<>();
+    for (ChangeInfo changeInfo : revertSubmissionInfo.revertChanges) {
+      results.add(gApi.changes().id(changeInfo._number));
+    }
+    return results;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 07fb577..70e4f89 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -1265,7 +1265,7 @@
     // Newly created group is not stale
     GroupInfo groupInfo = gApi.groups().create(name("foo")).get();
     AccountGroup.UUID groupUuid = AccountGroup.uuid(groupInfo.id);
-    assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
+    assertThat(stalenessChecker.check(groupUuid).isStale()).isFalse();
 
     // Manual update makes index document stale
     String groupRef = RefNames.refsGroups(groupUuid);
@@ -1406,11 +1406,11 @@
   private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
     // Evict group from cache to be sure that we use the index state for staleness checks.
     groupCache.evict(groupUuid);
-    assertThat(stalenessChecker.isStale(groupUuid)).isTrue();
+    assertThat(stalenessChecker.check(groupUuid).isStale()).isTrue();
 
     // Reindex fixes staleness
     groupIndexer.index(groupUuid);
-    assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
+    assertThat(stalenessChecker.check(groupUuid).isStale()).isFalse();
   }
 
   private void pushToGroupBranchForReviewAndSubmit(
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index eebcc5b..1a8790a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.api.projects.CommentLinkInput;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
@@ -717,6 +718,139 @@
     assertCommentLinks(getConfig(), expected);
   }
 
+  @Test
+  public void projectConfigUsesLocallySetCommentlinks() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
+  @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
+  @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
+  public void projectConfigUsesCommentLinksFromGlobalAndLocal() throws Exception {
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    assertCommentLinks(getConfig(), expected);
+
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
+  @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
+  @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
+  public void localCommentLinkOverridesGlobalConfig() throws Exception {
+    String otherLink = "https://other.example.com";
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, otherLink);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, otherLink));
+
+    ConfigInfo info = setConfig(project, input);
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
+  public void localCommentLinksAreInheritedFromParent() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
+    assertCommentLinks(getConfig(child), expected);
+  }
+
+  @Test
+  public void localCommentLinkOverridesParentCommentLink() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
+
+    String otherLink = "https://other.example.com";
+    input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, otherLink);
+    info = setConfig(child, input);
+
+    expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, otherLink));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+
+    assertCommentLinks(getConfig(child), expected);
+  }
+
+  @Test
+  public void updateExistingCommentLink() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+
+    String otherLink = "https://other.example.com";
+    input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, otherLink);
+    info = setConfig(project, input);
+
+    expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, otherLink));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(getConfig(project), expected);
+  }
+
+  @Test
+  public void removeCommentLink() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+
+    input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, null);
+    info = setConfig(project, input);
+
+    expected = new HashMap<>();
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(getConfig(project), expected);
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
     return new CommentLinkInfoImpl(name, match, link, null /*html*/, null /*enabled*/);
   }
@@ -725,6 +859,21 @@
     assertThat(actual.commentlinks).containsExactlyEntriesIn(expected);
   }
 
+  private void addCommentLink(ConfigInput configInput, String name, String match, String link) {
+    CommentLinkInput commentLinkInput = new CommentLinkInput();
+    commentLinkInput.match = match;
+    commentLinkInput.link = link;
+    addCommentLink(configInput, name, commentLinkInput);
+  }
+
+  private void addCommentLink(
+      ConfigInput configInput, String name, CommentLinkInput commentLinkInput) {
+    if (configInput.commentLinks == null) {
+      configInput.commentLinks = new HashMap<>();
+    }
+    configInput.commentLinks.put(name, commentLinkInput);
+  }
+
   private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
     return gApi.projects().name(name.get()).config(input);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index 019df0e..023f43e 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -83,19 +83,19 @@
 
   @Test
   public void stalenessChecker_currentProject_notStale() throws Exception {
-    assertThat(stalenessChecker.isStale(project)).isFalse();
+    assertThat(stalenessChecker.check(project).isStale()).isFalse();
   }
 
   @Test
   public void stalenessChecker_currentProjectUpdates_isStale() throws Exception {
     updateProjectConfigWithoutIndexUpdate(project);
-    assertThat(stalenessChecker.isStale(project)).isTrue();
+    assertThat(stalenessChecker.check(project).isStale()).isTrue();
   }
 
   @Test
   public void stalenessChecker_parentProjectUpdates_isStale() throws Exception {
     updateProjectConfigWithoutIndexUpdate(allProjects);
-    assertThat(stalenessChecker.isStale(project)).isTrue();
+    assertThat(stalenessChecker.check(project).isStale()).isTrue();
   }
 
   @Test
@@ -106,10 +106,10 @@
       u.getConfig().getProject().setParentName(p1);
       u.save();
     }
-    assertThat(stalenessChecker.isStale(project)).isFalse();
+    assertThat(stalenessChecker.check(project).isStale()).isFalse();
 
     updateProjectConfigWithoutIndexUpdate(p1, c -> c.getProject().setParentName(p2));
-    assertThat(stalenessChecker.isStale(project)).isTrue();
+    assertThat(stalenessChecker.check(project).isStale()).isTrue();
   }
 
   private void updateProjectConfigWithoutIndexUpdate(Project.NameKey project) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 32941ff..ad73e0f 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1328,6 +1328,23 @@
   }
 
   @Test
+  public void cannotGetContentOfDirectory() throws Exception {
+    Map<String, String> files = ImmutableMap.of("dir/file1.txt", "content 1");
+    PushOneCommit.Result result =
+        pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThrows(
+        BadRequestException.class,
+        () ->
+            gApi.changes()
+                .id(result.getChangeId())
+                .revision(result.getCommit().name())
+                .file("dir")
+                .content());
+  }
+
+  @Test
   public void contentType() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/filter/BUILD b/javatests/com/google/gerrit/acceptance/filter/BUILD
new file mode 100644
index 0000000..22aead3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/filter/BUILD
@@ -0,0 +1,22 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob([
+        "*IT.java",
+    ]),
+    group = "filter",
+    labels = ["filter"],
+    deps = [
+        ":util",
+    ],
+)
+
+java_library(
+    name = "util",
+    testonly = True,
+    srcs = [
+        "FakeMustInitParamsFilter.java",
+        "FakeNoInitParamsFilter.java",
+    ],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/filter/FakeMustInitParamsFilter.java b/javatests/com/google/gerrit/acceptance/filter/FakeMustInitParamsFilter.java
new file mode 100644
index 0000000..89d268e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/filter/FakeMustInitParamsFilter.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.acceptance.filter;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+
+public class FakeMustInitParamsFilter implements Filter {
+
+  // `PARAM_X` and `PARAM_Y` are init param keys
+  private static final String INIT_PARAM_1 = "PARAM-1";
+  private static final String INIT_PARAM_2 = "PARAM-2";
+  // the map is used for testing
+  private static final Map<String, String> initParams = new HashMap<>();
+
+  @Override
+  public void init(FilterConfig filterConfig) throws ServletException {
+    initParams.put(INIT_PARAM_1, filterConfig.getInitParameter(INIT_PARAM_1));
+    initParams.put(INIT_PARAM_2, filterConfig.getInitParameter(INIT_PARAM_2));
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    chain.doFilter(request, response);
+  }
+
+  @Override
+  public void destroy() {
+    // do nothing.
+  }
+
+  // the function is used for testing
+  Map<String, String> getInitParams() {
+    return initParams;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/filter/FakeNoInitParamsFilter.java b/javatests/com/google/gerrit/acceptance/filter/FakeNoInitParamsFilter.java
new file mode 100644
index 0000000..6fd6366
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/filter/FakeNoInitParamsFilter.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.filter;
+
+import java.io.IOException;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+
+public class FakeNoInitParamsFilter implements Filter {
+  @Override
+  public void init(FilterConfig filterConfig) throws ServletException {
+    // no init params in this filter.
+    // do nothing.
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    chain.doFilter(request, response);
+  }
+
+  @Override
+  public void destroy() {
+    // do nothing.
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/filter/FilterClassIT.java b/javatests/com/google/gerrit/acceptance/filter/FilterClassIT.java
new file mode 100644
index 0000000..a23c5ce
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/filter/FilterClassIT.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.filter;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class FilterClassIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config enableFilter() throws ConfigInvalidException {
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[httpd]\n"
+            + "    filterClass = com.google.gerrit.acceptance.filter.FakeNoInitParamsFilter\n"
+            + "    filterClass = com.google.gerrit.acceptance.filter.FakeMustInitParamsFilter\n"
+            + "[filterClass \"com.google.gerrit.acceptance.filter.FakeMustInitParamsFilter\"]\n"
+            + "    PARAM-1 = hello\n"
+            + "    PARAM-2 = world\n");
+    return cfg;
+  }
+
+  @Test
+  public void filterLoad() {
+    FakeNoInitParamsFilter fakeNoInitParamsFilter =
+        server.getTestInjector().getBinding(FakeNoInitParamsFilter.class).getProvider().get();
+    Assert.assertNotNull(fakeNoInitParamsFilter);
+    FakeMustInitParamsFilter fakeMustInitParamsFilter =
+        server.getTestInjector().getBinding(FakeMustInitParamsFilter.class).getProvider().get();
+    Assert.assertNotNull(fakeMustInitParamsFilter);
+  }
+
+  @Test
+  public void filterInitParams() {
+    FakeMustInitParamsFilter fakeMustInitParamsFilter =
+        server.getTestInjector().getBinding(FakeMustInitParamsFilter.class).getProvider().get();
+    Assert.assertEquals(2, fakeMustInitParamsFilter.getInitParams().size());
+    Assert.assertEquals("hello", fakeMustInitParamsFilter.getInitParams().get("PARAM-1"));
+    Assert.assertEquals("world", fakeMustInitParamsFilter.getInitParams().get("PARAM-2"));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 8a284d9..83bc3eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -84,6 +84,7 @@
           RestCall.post("/changes/%s/rebase"),
           RestCall.post("/changes/%s/restore"),
           RestCall.post("/changes/%s/revert"),
+          RestCall.post("/changes/%s/revert_submission"),
           RestCall.get("/changes/%s/pure_revert"),
           RestCall.post("/changes/%s/submit"),
           RestCall.get("/changes/%s/submitted_together"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index f48a603..b8ab752 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -29,6 +29,7 @@
 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.LabelFunction;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -73,6 +74,7 @@
           RestCall.get("/projects/%s/branches"),
           RestCall.post("/projects/%s/branches:delete"),
           RestCall.put("/projects/%s/branches/new-branch"),
+          RestCall.get("/projects/%s/labels"),
           RestCall.get("/projects/%s/tags"),
           RestCall.post("/projects/%s/tags:delete"),
           RestCall.put("/projects/%s/tags/new-tag"),
@@ -80,7 +82,8 @@
               // GET /projects/<project>/branches/<branch>/commits is not implemented
               .expectedResponseCode(SC_NOT_FOUND)
               .build(),
-          RestCall.get("/projects/%s/dashboards"));
+          RestCall.get("/projects/%s/dashboards"),
+          RestCall.put("/projects/%s/labels/new-label"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
@@ -158,6 +161,18 @@
   private static final ImmutableList<RestCall> COMMIT_FILE_ENDPOINTS =
       ImmutableList.of(RestCall.get("/projects/%s/commits/%s/files/%s/content"));
 
+  /**
+   * Label REST endpoints to be tested, each URL contains placeholders for the project identifier
+   * and the label name.
+   */
+  private static final ImmutableList<RestCall> LABEL_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/labels/%s"),
+          RestCall.put("/projects/%s/labels/%s"),
+
+          // Label deletion must be tested last
+          RestCall.delete("/projects/%s/labels/%s"));
+
   private static final String FILENAME = "test.txt";
   @Inject private ProjectOperations projectOperations;
 
@@ -212,6 +227,13 @@
         adminRestSession, COMMIT_FILE_ENDPOINTS, project.get(), commit, FILENAME);
   }
 
+  @Test
+  public void labelEndpoints() throws Exception {
+    String label = "Foo-Review";
+    configLabel(label, LabelFunction.NO_OP);
+    RestApiCallHelper.execute(adminRestSession, LABEL_ENDPOINTS, project.get(), label);
+  }
+
   private String createAndSubmitChange(String filename) throws Exception {
     RevCommit c =
         testRepo
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index dda7bbd..8b51e7f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -63,11 +63,25 @@
     return gApi.changes().id(id).revision(1).actions();
   }
 
+  protected Map<String, ActionInfo> getChangeActions(String id) throws Exception {
+    return gApi.changes().id(id).get().actions;
+  }
+
   protected String getETag(String id) throws Exception {
     return gApi.changes().id(id).current().etag();
   }
 
   @Test
+  public void changeActionOneMergedChangeHasReverts() throws Exception {
+    String changeId = createChangeWithTopic().getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId).current().submit();
+    Map<String, ActionInfo> actions = getChangeActions(changeId);
+    assertThat(actions).containsKey("revert");
+    assertThat(actions).containsKey("revert_submission");
+  }
+
+  @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     Map<String, ActionInfo> actions = getActions(changeId);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index a1167ed..10bae39 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -606,6 +607,67 @@
     }
   }
 
+  @Test
+  public void createChangeWithSubmittedMergeSource() throws Exception {
+    // Provide coverage for a performance optimization in CommitsCollection#canRead.
+    BranchInput branchInput = new BranchInput();
+    String mergeTarget = "refs/heads/new-branch";
+    RevCommit startCommit = projectOperations.project(project).getHead("master");
+
+    branchInput.revision = startCommit.name();
+    branchInput.ref = mergeTarget;
+
+    gApi.projects().name(project.get()).branch(mergeTarget).create(branchInput);
+
+    PushOneCommit.Result result1 =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "subject1", ImmutableMap.of("file1.txt", "content 1"))
+            .to("refs/for/master");
+    result1.assertOkStatus();
+
+    testRepo.branch("HEAD").update(startCommit);
+    PushOneCommit.Result result2 =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "subject2", ImmutableMap.of("file2.txt", "content 2"))
+            .to("refs/for/master");
+    result2.assertOkStatus();
+
+    ReviewInput reviewInput = ReviewInput.approve().label("Code-Review", 2);
+
+    gApi.changes().id(result1.getChangeId()).revision("current").review(reviewInput);
+    gApi.changes().id(result1.getChangeId()).revision("current").submit();
+
+    gApi.changes().id(result2.getChangeId()).revision("current").review(reviewInput);
+    gApi.changes().id(result2.getChangeId()).revision("current").submit();
+
+    String mergeRev = gApi.projects().name(project.get()).branch("master").get().revision;
+    RevCommit mergeCommit = projectOperations.project(project).getHead("master");
+    assertThat(mergeCommit.getParents().length).isEqualTo(2);
+
+    testRepo.git().fetch().call();
+    testRepo.branch("HEAD").update(mergeCommit);
+    PushOneCommit.Result result3 =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "subject3", ImmutableMap.of("file1.txt", "content 3"))
+            .to("refs/for/master");
+    result2.assertOkStatus();
+    gApi.changes().id(result3.getChangeId()).revision("current").review(reviewInput);
+    gApi.changes().id(result3.getChangeId()).revision("current").submit();
+
+    // Now master doesn't point directly to mergeRev
+    ChangeInput in = new ChangeInput();
+    in.branch = mergeTarget;
+    in.merge = new MergeInput();
+    in.project = project.get();
+    in.merge.source = mergeRev;
+    in.subject = "propagate merge";
+
+    gApi.changes().create(in);
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index e082559..b50a12b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -6,6 +6,7 @@
     group = f[:f.index(".")],
     labels = ["rest"],
     deps = [
+        ":labelassert",
         ":project",
         ":push_tag_util",
         ":refassert",
@@ -14,6 +15,19 @@
 ) for f in glob(["*IT.java"])]
 
 java_library(
+    name = "labelassert",
+    srcs = [
+        "LabelAssert.java",
+    ],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//lib/truth",
+    ],
+)
+
+java_library(
     name = "refassert",
     srcs = [
         "RefAssert.java",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
new file mode 100644
index 0000000..28e8b14
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -0,0 +1,596 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class CreateLabelIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .label("Foo-Review")
+                    .create(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .label("Foo-Review")
+                    .create(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void cannotCreateLabelIfNameDoesntMatch() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "Foo";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Bar").create(input));
+    assertThat(thrown).hasMessageThat().contains("name in input must match name in URL");
+  }
+
+  @Test
+  public void cannotCreateLabelWithNameThatIsAlreadyInUse() throws Exception {
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .label("Code-Review")
+                    .create(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("label Code-Review already exists");
+  }
+
+  @Test
+  public void cannotCreateLabelWithNameThatConflicts() throws Exception {
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .label("code-review")
+                    .create(new LabelDefinitionInput()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("label code-review conflicts with existing label Code-Review");
+  }
+
+  @Test
+  public void cannotCreateLabelWithInvalidName() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("INVALID_NAME").create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid name: INVALID_NAME");
+  }
+
+  @Test
+  public void cannotCreateLabelWithoutValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("values are required");
+
+    input.values = ImmutableMap.of();
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("values are required");
+  }
+
+  @Test
+  public void cannotCreateLabelWithInvalidValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("invalidValue", "description");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid value: invalidValue");
+  }
+
+  @Test
+  public void cannotCreateLabelWithValuesThatHaveEmptyDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("description for value '+1' cannot be empty");
+  }
+
+  @Test
+  public void cannotCreateLabelWithInvalidDefaultValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+    input.defaultValue = 5;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid default value: " + input.defaultValue);
+  }
+
+  @Test
+  public void cannotCreateLabelWithUnknownFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+    input.function = "UnknownFuction";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("unknown function: " + input.function);
+  }
+
+  @Test
+  public void cannotCreateLabelWithInvalidBranch() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+    input.branches = ImmutableList.of("refs heads master");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid branch: refs heads master");
+  }
+
+  @Test
+  public void createWithNameAndValuesOnly() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.name).isEqualTo("Foo");
+    assertThat(createdLabel.projectName).isEqualTo(project.get());
+    assertThat(createdLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
+    assertThat(createdLabel.values).containsExactlyEntriesIn(input.values);
+    assertThat(createdLabel.defaultValue).isEqualTo(0);
+    assertThat(createdLabel.branches).isNull();
+    assertThat(createdLabel.canOverride).isTrue();
+    assertThat(createdLabel.copyAnyScore).isNull();
+    assertThat(createdLabel.copyMinScore).isNull();
+    assertThat(createdLabel.copyMaxScore).isNull();
+    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
+    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
+    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(createdLabel.allowPostSubmit).isTrue();
+    assertThat(createdLabel.ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void createWithFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.function = LabelFunction.NO_OP.getFunctionName();
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
+  }
+
+  @Test
+  public void functionEmptyAfterTrim() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.function = " ";
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
+  }
+
+  @Test
+  public void valuesAndDescriptionsAreTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Positive values can be specified as '<value>' or '+<value>'.
+    input.values =
+        ImmutableMap.of(
+            " 2 ",
+            " Looks Very Good ",
+            " +1 ",
+            " Looks Good ",
+            " 0 ",
+            " Don't Know ",
+            " -1 ",
+            " Looks Bad ",
+            " -2 ",
+            " Looks Very Bad ");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+  }
+
+  @Test
+  public void createWithDefaultValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.defaultValue = 1;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.defaultValue).isEqualTo(input.defaultValue);
+  }
+
+  @Test
+  public void createWithBranches() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    // Branches can be full ref, ref pattern or regular expression.
+    input.branches =
+        ImmutableList.of("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.branches).containsExactlyElementsIn(input.branches);
+  }
+
+  @Test
+  public void branchesAreTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches =
+        ImmutableList.of(" refs/heads/master ", " refs/heads/foo/* ", " ^refs/heads/stable-.* ");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.branches)
+        .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+  }
+
+  @Test
+  public void emptyBranchesAreIgnored() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = ImmutableList.of("refs/heads/master", "", " ");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.branches).containsExactly("refs/heads/master");
+  }
+
+  @Test
+  public void branchesAreAutomaticallyPrefixedWithRefsHeads() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = ImmutableList.of("master", "refs/meta/config");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.branches).containsExactly("refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void createWithCanOverride() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.canOverride = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.canOverride).isTrue();
+  }
+
+  @Test
+  public void createWithoutCanOverride() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.canOverride = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.canOverride).isNull();
+  }
+
+  @Test
+  public void createWithCopyAnyScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAnyScore = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAnyScore).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAnyScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAnyScore = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAnyScore).isNull();
+  }
+
+  @Test
+  public void createWithCopyMinScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyMinScore = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyMinScore).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyMinScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyMinScore = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyMinScore).isNull();
+  }
+
+  @Test
+  public void createWithCopyMaxScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyMaxScore = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyMaxScore).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyMaxScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyMaxScore = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyMaxScore).isNull();
+  }
+
+  @Test
+  public void createWithCopyAllScoresIfNoChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfNoChange = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresIfNoChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfNoChange = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfNoChange).isNull();
+  }
+
+  @Test
+  public void createWithCopyAllScoresIfNoCodeChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfNoCodeChange = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresIfNoCodeChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfNoCodeChange = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
+  }
+
+  @Test
+  public void createWithCopyAllScoresOnTrivialRebase() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresOnTrivialRebase = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresOnTrivialRebase() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresOnTrivialRebase = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
+  }
+
+  @Test
+  public void createWithCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresOnMergeFirstParentUpdate = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresOnMergeFirstParentUpdate = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+  }
+
+  @Test
+  public void createWithAllowPostSubmit() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.allowPostSubmit = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.allowPostSubmit).isTrue();
+  }
+
+  @Test
+  public void createWithoutAllowPostSubmit() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.allowPostSubmit = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.allowPostSubmit).isNull();
+  }
+
+  @Test
+  public void createWithIgnoreSelfApproval() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.ignoreSelfApproval = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.ignoreSelfApproval).isTrue();
+  }
+
+  @Test
+  public void createWithoutIgnoreSelfApproval() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.ignoreSelfApproval = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    gApi.projects().name(project.get()).label("Foo").create(input);
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update label");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.commitMessage = "Add Foo Label";
+    gApi.projects().name(project.get()).label("Foo").create(input);
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.commitMessage = " Add Foo Label ";
+    gApi.projects().name(project.get()).label("Foo").create(input);
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Add Foo Label");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
new file mode 100644
index 0000000..c916285
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.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.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class DeleteLabelIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void nonExisting() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Non-Existing-Review").delete());
+    assertThat(thrown).hasMessageThat().contains("Not found: Non-Existing-Review");
+  }
+
+  @Test
+  public void delete() throws Exception {
+    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).label("Code-Review").get());
+    assertThat(thrown).hasMessageThat().contains("Not found: Code-Review");
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Delete label");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    gApi.projects().name(allProjects.get()).label("Code-Review").delete("Delete Code-Review label");
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Delete Code-Review label");
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    gApi.projects()
+        .name(allProjects.get())
+        .label("Code-Review")
+        .delete(" Delete Code-Review label ");
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Delete Code-Review label");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
new file mode 100644
index 0000000..9f98490
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.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.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class GetLabelIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void notFound() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).label("Foo-Review").get());
+    assertThat(thrown).hasMessageThat().contains("Not found: Foo-Review");
+  }
+
+  @Test
+  public void allProjectsCodeReviewLabel() throws Exception {
+    LabelDefinitionInfo codeReviewLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").get();
+    LabelAssert.assertCodeReviewLabel(codeReviewLabel);
+  }
+
+  @Test
+  public void labelWithDefaultValue() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // set default value
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setDefaultValue((short) 1);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
+    assertThat(fooLabel.defaultValue).isEqualTo(1);
+  }
+
+  @Test
+  public void labelLimitedToBranches() throws Exception {
+    configLabel(
+        "foo", LabelFunction.NO_OP, ImmutableList.of("refs/heads/master", "^refs/heads/stable-.*"));
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
+    assertThat(fooLabel.branches).containsExactly("refs/heads/master", "^refs/heads/stable-.*");
+  }
+
+  @Test
+  public void labelWithoutRules() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // unset rules which are enabled by default
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCanOverride(false);
+      labelType.setCopyAllScoresIfNoChange(false);
+      labelType.setAllowPostSubmit(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
+    assertThat(fooLabel.canOverride).isNull();
+    assertThat(fooLabel.copyAnyScore).isNull();
+    assertThat(fooLabel.copyMinScore).isNull();
+    assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
+    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
+    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
+    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(fooLabel.allowPostSubmit).isNull();
+    assertThat(fooLabel.ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void labelWithAllRules() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // set rules which are not enabled by default
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAnyScore(true);
+      labelType.setCopyMinScore(true);
+      labelType.setCopyMaxScore(true);
+      labelType.setCopyAllScoresIfNoCodeChange(true);
+      labelType.setCopyAllScoresOnTrivialRebase(true);
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+      labelType.setIgnoreSelfApproval(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
+    assertThat(fooLabel.canOverride).isTrue();
+    assertThat(fooLabel.copyAnyScore).isTrue();
+    assertThat(fooLabel.copyMinScore).isTrue();
+    assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
+    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
+    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+    assertThat(fooLabel.allowPostSubmit).isTrue();
+    assertThat(fooLabel.ignoreSelfApproval).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
new file mode 100644
index 0000000..7998ecb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+
+public class LabelAssert {
+  public static void assertCodeReviewLabel(LabelDefinitionInfo codeReviewLabel) {
+    assertThat(codeReviewLabel.name).isEqualTo("Code-Review");
+    assertThat(codeReviewLabel.projectName).isEqualTo(AllProjectsNameProvider.DEFAULT);
+    assertThat(codeReviewLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
+    assertThat(codeReviewLabel.values)
+        .containsExactly(
+            "+2",
+            "Looks good to me, approved",
+            "+1",
+            "Looks good to me, but someone else must approve",
+            " 0",
+            "No score",
+            "-1",
+            "I would prefer this is not merged as is",
+            "-2",
+            "This shall not be merged");
+    assertThat(codeReviewLabel.defaultValue).isEqualTo(0);
+    assertThat(codeReviewLabel.branches).isNull();
+    assertThat(codeReviewLabel.canOverride).isTrue();
+    assertThat(codeReviewLabel.copyAnyScore).isNull();
+    assertThat(codeReviewLabel.copyMinScore).isTrue();
+    assertThat(codeReviewLabel.copyMaxScore).isNull();
+    assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
+    assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
+    assertThat(codeReviewLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(codeReviewLabel.allowPostSubmit).isTrue();
+    assertThat(codeReviewLabel.ignoreSelfApproval).isNull();
+  }
+
+  private LabelAssert() {}
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
new file mode 100644
index 0000000..d2539e5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -0,0 +1,268 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+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.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class ListLabelsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).labels().get());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).labels().get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void noLabels() throws Exception {
+    assertThat(gApi.projects().name(project.get()).labels().get()).isEmpty();
+  }
+
+  @Test
+  public void allProjectsLabels() throws Exception {
+    List<LabelDefinitionInfo> labels = gApi.projects().name(allProjects.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review");
+
+    LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
+    LabelAssert.assertCodeReviewLabel(codeReviewLabel);
+  }
+
+  @Test
+  public void labelsAreSortedByName() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    configLabel("bar", LabelFunction.NO_OP);
+    configLabel("baz", LabelFunction.NO_OP);
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("bar", "baz", "foo").inOrder();
+  }
+
+  @Test
+  public void labelWithDefaultValue() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // set default value
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setDefaultValue((short) 1);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.defaultValue).isEqualTo(1);
+  }
+
+  @Test
+  public void labelLimitedToBranches() throws Exception {
+    configLabel(
+        "foo", LabelFunction.NO_OP, ImmutableList.of("refs/heads/master", "^refs/heads/stable-.*"));
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.branches).containsExactly("refs/heads/master", "^refs/heads/stable-.*");
+  }
+
+  @Test
+  public void labelWithoutRules() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // unset rules which are enabled by default
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCanOverride(false);
+      labelType.setCopyAllScoresIfNoChange(false);
+      labelType.setAllowPostSubmit(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.canOverride).isNull();
+    assertThat(fooLabel.copyAnyScore).isNull();
+    assertThat(fooLabel.copyMinScore).isNull();
+    assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
+    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
+    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
+    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(fooLabel.allowPostSubmit).isNull();
+    assertThat(fooLabel.ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void labelWithAllRules() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // set rules which are not enabled by default
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAnyScore(true);
+      labelType.setCopyMinScore(true);
+      labelType.setCopyMaxScore(true);
+      labelType.setCopyAllScoresIfNoCodeChange(true);
+      labelType.setCopyAllScoresOnTrivialRebase(true);
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+      labelType.setIgnoreSelfApproval(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.canOverride).isTrue();
+    assertThat(fooLabel.copyAnyScore).isTrue();
+    assertThat(fooLabel.copyMinScore).isTrue();
+    assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
+    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
+    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+    assertThat(fooLabel.allowPostSubmit).isTrue();
+    assertThat(fooLabel.ignoreSelfApproval).isTrue();
+  }
+
+  @Test
+  public void withInheritedLabelsNotAllowed() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // can list labels without inheritance
+    gApi.projects().name(project.get()).labels().get();
+
+    // cannot list labels with inheritance
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).labels().withInherited(true).get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("All-Projects: read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void inheritedLabelsOnly() throws Exception {
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(project.get()).labels().withInherited(true).get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review");
+
+    LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
+    LabelAssert.assertCodeReviewLabel(codeReviewLabel);
+  }
+
+  @Test
+  public void withInheritedLabels() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    configLabel("bar", LabelFunction.NO_OP);
+    configLabel("baz", LabelFunction.NO_OP);
+
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(project.get()).labels().withInherited(true).get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review", "bar", "baz", "foo").inOrder();
+
+    LabelAssert.assertCodeReviewLabel(labels.get(0));
+    assertThat(labels.get(1).name).isEqualTo("bar");
+    assertThat(labels.get(1).projectName).isEqualTo(project.get());
+    assertThat(labels.get(2).name).isEqualTo("baz");
+    assertThat(labels.get(2).projectName).isEqualTo(project.get());
+    assertThat(labels.get(3).name).isEqualTo("foo");
+    assertThat(labels.get(3).projectName).isEqualTo(project.get());
+  }
+
+  @Test
+  public void withInheritedLabelsAndOverriddenLabel() throws Exception {
+    configLabel("Code-Review", LabelFunction.NO_OP);
+
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(project.get()).labels().withInherited(true).get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review", "Code-Review");
+
+    LabelAssert.assertCodeReviewLabel(labels.get(0));
+    assertThat(labels.get(1).name).isEqualTo("Code-Review");
+    assertThat(labels.get(1).projectName).isEqualTo(project.get());
+    assertThat(labels.get(1).function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
+  }
+
+  @Test
+  public void withInheritedLabelsFromMultipleParents() throws Exception {
+    configLabel(project, "foo", LabelFunction.NO_OP);
+
+    Project.NameKey childProject =
+        projectOperations.newProject().name("child").parent(project).create();
+    configLabel(childProject, "bar", LabelFunction.NO_OP);
+
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(childProject.get()).labels().withInherited(true).get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review", "foo", "bar").inOrder();
+
+    LabelAssert.assertCodeReviewLabel(labels.get(0));
+    assertThat(labels.get(1).name).isEqualTo("foo");
+    assertThat(labels.get(1).projectName).isEqualTo(project.get());
+    assertThat(labels.get(2).name).isEqualTo("bar");
+    assertThat(labels.get(2).projectName).isEqualTo(childProject.get());
+  }
+
+  private static List<String> labelNames(List<LabelDefinitionInfo> labels) {
+    return labels.stream().map(l -> l.name).collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
new file mode 100644
index 0000000..9cba930
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -0,0 +1,880 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+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.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+@NoHttpd
+public class SetLabelIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .label("Code-Review")
+                    .update(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .label("Code-Review")
+                    .update(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void updateName() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "Foo-Review";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.name).isEqualTo(input.name);
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+  }
+
+  @Test
+  public void nameIsTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = " Foo-Review ";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.name).isEqualTo("Foo-Review");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+  }
+
+  @Test
+  public void cannotSetEmptyName() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("name cannot be empty");
+  }
+
+  @Test
+  public void cannotSetInvalidName() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "INVALID_NAME";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("invalid name: " + input.name);
+  }
+
+  @Test
+  public void cannotSetNameIfNameClashes() throws Exception {
+    configLabel("Foo-Review", LabelFunction.NO_OP);
+    configLabel("Bar-Review", LabelFunction.NO_OP);
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "Bar-Review";
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).label("Foo-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("name " + input.name + " already in use");
+  }
+
+  @Test
+  public void cannotSetNameIfNameConflicts() throws Exception {
+    configLabel("Foo-Review", LabelFunction.NO_OP);
+    configLabel("Bar-Review", LabelFunction.NO_OP);
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "bar-review";
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).label("Foo-Review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("name bar-review conflicts with existing label Bar-Review");
+  }
+
+  @Test
+  public void updateFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.function).isEqualTo(input.function);
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+        .isEqualTo(input.function);
+  }
+
+  @Test
+  public void functionIsTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = " " + LabelFunction.NO_OP.getFunctionName() + " ";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+        .isEqualTo(LabelFunction.NO_OP.getFunctionName());
+  }
+
+  @Test
+  public void cannotSetEmptyFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("function cannot be empty");
+  }
+
+  @Test
+  public void cannotSetUnknownFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "UnknownFunction";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("unknown function: " + input.function);
+  }
+
+  @Test
+  public void cannotSetEmptyValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of();
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("values cannot be empty");
+  }
+
+  @Test
+  public void updateValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Positive values can be specified as '<value>' or '+<value>'.
+    input.values =
+        ImmutableMap.of(
+            "2",
+            "Looks Very Good",
+            "+1",
+            "Looks Good",
+            "0",
+            "Don't Know",
+            "-1",
+            "Looks Bad",
+            "-2",
+            "Looks Very Bad");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+  }
+
+  @Test
+  public void valuesAndDescriptionsAreTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Positive values can be specified as '<value>' or '+<value>'.
+    input.values =
+        ImmutableMap.of(
+            " 2 ",
+            " Looks Very Good ",
+            " +1 ",
+            " Looks Good ",
+            " 0 ",
+            " Don't Know ",
+            " -1 ",
+            " Looks Bad ",
+            " -2 ",
+            " Looks Very Bad ");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+  }
+
+  @Test
+  public void cannotSetInvalidValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("invalidValue", "description");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("invalid value: invalidValue");
+  }
+
+  @Test
+  public void cannotSetValueWithEmptyDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("description for value '+1' cannot be empty");
+  }
+
+  @Test
+  public void updateDefaultValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.defaultValue = 1;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.defaultValue).isEqualTo(input.defaultValue);
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().defaultValue)
+        .isEqualTo(input.defaultValue);
+  }
+
+  @Test
+  public void cannotSetInvalidDefaultValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.defaultValue = 5;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("invalid default value: " + input.defaultValue);
+  }
+
+  @Test
+  public void updateBranches() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Branches can be full ref, ref pattern or regular expression.
+    input.branches =
+        ImmutableList.of("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches).containsExactlyElementsIn(input.branches);
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .containsExactlyElementsIn(input.branches);
+  }
+
+  @Test
+  public void branchesAreTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches =
+        ImmutableList.of(" refs/heads/master ", " refs/heads/foo/* ", " ^refs/heads/stable-.* ");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches)
+        .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+  }
+
+  @Test
+  public void emptyBranchesAreIgnored() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches = ImmutableList.of("refs/heads/master", "", " ");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches).containsExactly("refs/heads/master");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .containsExactly("refs/heads/master");
+  }
+
+  @Test
+  public void branchesCanBeUnset() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches = ImmutableList.of("refs/heads/master");
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .isNotNull();
+
+    input.branches = ImmutableList.of();
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches).isNull();
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .isNull();
+  }
+
+  @Test
+  public void cannotSetInvalidBranch() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches = ImmutableList.of("refs heads master");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("invalid branch: refs heads master");
+  }
+
+  @Test
+  public void branchesAreAutomaticallyPrefixedWithRefsHeads() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches = ImmutableList.of("master", "refs/meta/config");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches).containsExactly("refs/heads/master", "refs/meta/config");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .containsExactly("refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void setCanOverride() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCanOverride(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.canOverride = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.canOverride).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isTrue();
+  }
+
+  @Test
+  public void unsetCanOverride() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.canOverride = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.canOverride).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isNull();
+  }
+
+  @Test
+  public void setCopyAnyScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAnyScore = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAnyScore).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
+  }
+
+  @Test
+  public void unsetCopyAnyScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAnyScore(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAnyScore = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAnyScore).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
+  }
+
+  @Test
+  public void setCopyMinScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyMinScore = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyMinScore).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
+  }
+
+  @Test
+  public void unsetCopyMinScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyMinScore(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyMinScore = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyMinScore).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
+  }
+
+  @Test
+  public void setCopyMaxScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyMaxScore = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyMaxScore).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
+  }
+
+  @Test
+  public void unsetCopyMaxScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyMaxScore(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyMaxScore = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyMaxScore).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
+  }
+
+  @Test
+  public void setCopyAllScoresIfNoChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAllScoresIfNoChange(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfNoChange = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfNoChange).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfNoChange = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfNoChange).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
+        .isNull();
+  }
+
+  @Test
+  public void setCopyAllScoresIfNoCodeChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfNoCodeChange = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfNoCodeChange = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
+        .isNull();
+  }
+
+  @Test
+  public void setCopyAllScoresOnTrivialRebase() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresOnTrivialRebase = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAllScoresOnTrivialRebase(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresOnTrivialRebase = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
+        .isNull();
+  }
+
+  @Test
+  public void setCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresOnMergeFirstParentUpdate)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresOnMergeFirstParentUpdate = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresOnMergeFirstParentUpdate)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresOnMergeFirstParentUpdate)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresOnMergeFirstParentUpdate = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresOnMergeFirstParentUpdate)
+        .isNull();
+  }
+
+  @Test
+  public void setAllowPostSubmit() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setAllowPostSubmit(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.allowPostSubmit = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.allowPostSubmit).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
+  }
+
+  @Test
+  public void unsetAllowPostSubmit() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.allowPostSubmit = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.allowPostSubmit).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
+  }
+
+  @Test
+  public void setIgnoreSelfApproval() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.ignoreSelfApproval).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isTrue();
+  }
+
+  @Test
+  public void unsetIgnoreSelfApproval() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setIgnoreSelfApproval(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.ignoreSelfApproval).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects()
+            .name(allProjects.get())
+            .label("Code-Review")
+            .update(new LabelDefinitionInput());
+    LabelAssert.assertCodeReviewLabel(updatedLabel);
+
+    LabelAssert.assertCodeReviewLabel(
+        gApi.projects().name(allProjects.get()).label("Code-Review").get());
+
+    assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update label");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+    input.commitMessage = "Set NoOp function";
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+    input.commitMessage = " Set NoOp function ";
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Set NoOp function");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index b94a709..fd6c512 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -55,6 +55,7 @@
         "//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/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 054b1aa..2f64ed0 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -18,9 +18,9 @@
 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.cache.CacheBuilder;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
@@ -44,7 +44,6 @@
 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;
 
@@ -52,8 +51,7 @@
 public class ExternalIDCacheLoaderTest {
   private static AllUsersName ALL_USERS = new AllUsersName(AllUsersNameProvider.DEFAULT);
 
-  @Mock Cache<ObjectId, AllExternalIds> externalIdCache;
-
+  private Cache<ObjectId, AllExternalIds> externalIdCache;
   private ExternalIdCacheLoader loader;
   private GitRepositoryManager repoManager = new InMemoryRepositoryManager();
   private ExternalIdReader externalIdReader;
@@ -61,6 +59,7 @@
 
   @Before
   public void setUp() throws Exception {
+    externalIdCache = CacheBuilder.newBuilder().build();
     repoManager.createRepository(ALL_USERS).close();
     externalIdReader = new ExternalIdReader(repoManager, ALL_USERS, new DisabledMetricMaker());
     externalIdReaderSpy = Mockito.spy(externalIdReader);
@@ -78,8 +77,7 @@
   public void reloadsSingleUpdateUsingPartialReload() throws Exception {
     ObjectId firstState = insertExternalId(1, 1);
     ObjectId head = insertExternalId(2, 2);
-
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -91,8 +89,7 @@
     insertExternalId(2, 2);
     insertExternalId(3, 3);
     ObjectId head = insertExternalId(4, 4);
-
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -100,11 +97,9 @@
 
   @Test
   public void reloadsAllExternalIdsWhenNoOldStateIsCached() throws Exception {
-    ObjectId firstState = insertExternalId(1, 1);
+    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);
   }
@@ -144,8 +139,7 @@
     ObjectId firstState = insertExternalId(1, 1);
     ObjectId head = deleteExternalId(1, 1);
     assertThat(allFromGit(head).byAccount().size()).isEqualTo(0);
-
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -159,8 +153,7 @@
             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));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -177,7 +170,7 @@
       head = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
     }
 
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -190,8 +183,7 @@
     ObjectId oldState = inserExternalIds(257);
     assertAllFilesHaveSlashesInPath();
     ObjectId head = insertExternalId(500, 500);
-
-    when(externalIdCache.getIfPresent(oldState)).thenReturn(allFromGit(oldState));
+    externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -205,8 +197,7 @@
     // 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));
+    externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
diff --git a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
index 6175385..63f83b0 100644
--- a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
+++ b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
@@ -21,10 +21,13 @@
 import com.google.common.collect.SortedSetMultimap;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.git.receive.ReceivePackRefCache;
+import java.io.IOException;
 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.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -44,8 +47,7 @@
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(a, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(a, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
   }
@@ -56,8 +58,7 @@
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(a).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(b, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(b, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -67,12 +68,12 @@
   public void commitWhoseParentIsExistingPatchSetGetsParentsGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(a).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(
-            newWalk(b, branchTip), patchSets().put(a, psId(1, 1)), groups().put(psId(1, 1), group));
+        collectGroups(newWalk(b, branchTip), groups().put(psId(1, 1), group));
 
     assertThat(groups).containsEntry(a, group);
     assertThat(groups).containsEntry(b, group);
@@ -84,8 +85,7 @@
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(a).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(b, branchTip), patchSets().put(a, psId(1, 1)), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(b, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -98,8 +98,7 @@
     RevCommit b = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(m, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(m, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -110,13 +109,13 @@
   public void mergeCommitWhereOneParentHasExistingGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(
-            newWalk(m, branchTip), patchSets().put(b, psId(1, 1)), groups().put(psId(1, 1), group));
+        collectGroups(newWalk(m, branchTip), groups().put(psId(1, 1), group));
 
     // Merge commit and other parent get the existing group.
     assertThat(groups).containsEntry(a, group);
@@ -128,16 +127,16 @@
   public void mergeCommitWhereBothParentsHaveDifferentGroups() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(branchTip).create();
+    createRef(psId(2, 1), b, tr);
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
     String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     String group2 = "1234567812345678123456781234567812345678";
     SortedSetMultimap<ObjectId, String> groups =
         collectGroups(
-            newWalk(m, branchTip),
-            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
-            groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
+            newWalk(m, branchTip), groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
 
     assertThat(groups).containsEntry(a, group1);
     assertThat(groups).containsEntry(b, group2);
@@ -149,7 +148,9 @@
   public void mergeCommitMergesGroupsFromParent() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(branchTip).create();
+    createRef(psId(2, 1), b, tr);
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
     String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
@@ -158,7 +159,6 @@
     SortedSetMultimap<ObjectId, String> groups =
         collectGroups(
             newWalk(m, branchTip),
-            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
             groups().put(psId(1, 1), group1).put(psId(2, 1), group2a).put(psId(2, 1), group2b));
 
     assertThat(groups).containsEntry(a, group1);
@@ -171,12 +171,12 @@
   public void mergeCommitWithOneUninterestingParentAndOtherParentIsExisting() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit m = tr.commit().parent(branchTip).parent(a).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(
-            newWalk(m, branchTip), patchSets().put(a, psId(1, 1)), groups().put(psId(1, 1), group));
+        collectGroups(newWalk(m, branchTip), groups().put(psId(1, 1), group));
 
     assertThat(groups).containsEntry(a, group);
     assertThat(groups).containsEntry(m, group);
@@ -188,8 +188,7 @@
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(branchTip).parent(a).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(m, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(m, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(m, a.name());
@@ -204,8 +203,7 @@
     RevCommit m1 = tr.commit().parent(b).parent(c).create();
     RevCommit m2 = tr.commit().parent(a).parent(m1).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(m2, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(m2, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -223,8 +221,7 @@
     assertThat(m.getParentCount()).isEqualTo(2);
     assertThat(m.getParent(0)).isEqualTo(m.getParent(1));
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(m, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(m, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(m, a.name());
@@ -234,7 +231,9 @@
   public void mergeCommitWithOneNewParentAndTwoExistingPatchSets() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(branchTip).create();
+    createRef(psId(2, 1), b, tr);
     RevCommit c = tr.commit().parent(b).create();
     RevCommit m = tr.commit().parent(a).parent(c).create();
 
@@ -242,9 +241,7 @@
     String group2 = "1234567812345678123456781234567812345678";
     SortedSetMultimap<ObjectId, String> groups =
         collectGroups(
-            newWalk(m, branchTip),
-            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
-            groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
+            newWalk(m, branchTip), groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
 
     assertThat(groups).containsEntry(a, group1);
     assertThat(groups).containsEntry(b, group2);
@@ -264,16 +261,7 @@
     rw.markStart(rw.parseCommit(d));
     // Schema upgrade case: all commits are existing patch sets, but none have
     // groups assigned yet.
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(
-            rw,
-            patchSets()
-                .put(branchTip, psId(1, 1))
-                .put(a, psId(2, 1))
-                .put(b, psId(3, 1))
-                .put(c, psId(4, 1))
-                .put(d, psId(5, 1)),
-            groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(rw, groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -287,6 +275,13 @@
     return PatchSet.id(Change.id(c), p);
   }
 
+  private static void createRef(PatchSet.Id psId, ObjectId id, TestRepository<?> tr)
+      throws IOException {
+    RefUpdate ru = tr.getRepository().updateRef(psId.toRefName());
+    ru.setNewObjectId(id);
+    assertThat(ru.update()).isEqualTo(RefUpdate.Result.NEW);
+  }
+
   private RevWalk newWalk(ObjectId start, ObjectId branchTip) throws Exception {
     // Match RevWalk conditions from ReceiveCommits.
     RevWalk rw = new RevWalk(tr.getRepository());
@@ -297,12 +292,12 @@
     return rw;
   }
 
-  private static SortedSetMultimap<ObjectId, String> collectGroups(
-      RevWalk rw,
-      ImmutableListMultimap.Builder<ObjectId, PatchSet.Id> patchSetsBySha,
-      ImmutableListMultimap.Builder<PatchSet.Id, String> groupLookup)
-      throws Exception {
-    GroupCollector gc = new GroupCollector(patchSetsBySha.build(), groupLookup.build());
+  private SortedSetMultimap<ObjectId, String> collectGroups(
+      RevWalk rw, ImmutableListMultimap.Builder<PatchSet.Id, String> groupLookup) throws Exception {
+    ImmutableListMultimap<PatchSet.Id, String> groups = groupLookup.build();
+    GroupCollector gc =
+        new GroupCollector(
+            ReceivePackRefCache.noCache(tr.getRepository().getRefDatabase()), (s) -> groups.get(s));
     RevCommit c;
     while ((c = rw.next()) != null) {
       gc.visit(c);
@@ -310,12 +305,6 @@
     return gc.getGroups();
   }
 
-  // Helper methods for constructing various map arguments, to avoid lots of
-  // type specifications.
-  private static ImmutableListMultimap.Builder<ObjectId, PatchSet.Id> patchSets() {
-    return ImmutableListMultimap.builder();
-  }
-
   private static ImmutableListMultimap.Builder<PatchSet.Id, String> groups() {
     return ImmutableListMultimap.builder();
   }
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
new file mode 100644
index 0000000..698acd8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -0,0 +1,140 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectIdRef;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.junit.Test;
+
+/** Tests for {@link ReceivePackRefCache}. */
+public class ReceivePackRefCacheTest {
+
+  @Test
+  public void noCache_prefixDelegatesToRefDb() throws Exception {
+    Ref ref = newRef("refs/changes/01/1/1", "badc0feebadc0feebadc0feebadc0feebadc0fee");
+    RefDatabase mockRefDb = mock(RefDatabase.class);
+    ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
+    when(mockRefDb.getRefsByPrefix(RefNames.REFS_HEADS)).thenReturn(ImmutableList.of(ref));
+
+    assertThat(cache.byPrefix(RefNames.REFS_HEADS)).containsExactly(ref);
+    verify(mockRefDb).getRefsByPrefix(RefNames.REFS_HEADS);
+    verifyNoMoreInteractions(mockRefDb);
+  }
+
+  @Test
+  public void noCache_exactRefDelegatesToRefDb() throws Exception {
+    Ref ref = newRef("refs/changes/01/1/1", "badc0feebadc0feebadc0feebadc0feebadc0fee");
+    RefDatabase mockRefDb = mock(RefDatabase.class);
+    ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
+    when(mockRefDb.exactRef("refs/heads/master")).thenReturn(ref);
+
+    assertThat(cache.exactRef("refs/heads/master")).isEqualTo(ref);
+    verify(mockRefDb).exactRef("refs/heads/master");
+    verifyNoMoreInteractions(mockRefDb);
+  }
+
+  @Test
+  public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+    Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
+    Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+    RefDatabase mockRefDb = mock(RefDatabase.class);
+    ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
+    when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
+        .thenReturn(ImmutableSet.of(refBla, refheads));
+
+    assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
+        .containsExactly(refheads);
+    verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
+    verifyNoMoreInteractions(mockRefDb);
+  }
+
+  @Test
+  public void advertisedRefs_prefixScans() throws Exception {
+    Ref refBla =
+        new ObjectIdRef.Unpeeled(
+            Ref.Storage.NEW,
+            "refs/bla/1",
+            ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"),
+            1);
+    ReceivePackRefCache cache =
+        ReceivePackRefCache.withAdvertisedRefs(() -> ImmutableMap.of(refBla.getName(), refBla));
+
+    assertThat(cache.byPrefix("refs/bla")).containsExactly(refBla);
+  }
+
+  @Test
+  public void advertisedRefs_prefixScansChangeId() throws Exception {
+    Map<String, Ref> refs = setupTwoChanges();
+    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
+
+    assertThat(cache.byPrefix(RefNames.changeRefPrefix(Change.id(1))))
+        .containsExactly(refs.get("refs/changes/01/1/1"));
+  }
+
+  @Test
+  public void advertisedRefs_exactRef() throws Exception {
+    Map<String, Ref> refs = setupTwoChanges();
+    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
+
+    assertThat(cache.exactRef("refs/changes/01/1/1")).isEqualTo(refs.get("refs/changes/01/1/1"));
+  }
+
+  @Test
+  public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+    Map<String, Ref> refs = setupTwoChanges();
+    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
+
+    assertThat(
+            cache.tipsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
+        .containsExactly(refs.get("refs/changes/01/1/1"));
+  }
+
+  @Test
+  public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
+    Map<String, Ref> refs = setupTwoChanges();
+    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
+
+    assertThat(
+            cache.tipsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
+        .isEmpty();
+  }
+
+  private static Ref newRef(String name, String sha1) {
+    return new ObjectIdRef.Unpeeled(Ref.Storage.NEW, name, ObjectId.fromString(sha1), 1);
+  }
+
+  private Map<String, Ref> setupTwoChanges() {
+    Ref ref1 = newRef("refs/changes/01/1/1", "badc0feebadc0feebadc0feebadc0feebadc0fee");
+    Ref ref2 = newRef("refs/changes/02/2/1", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    return ImmutableMap.of(ref1.getName(), ref1, ref2.getName(), ref2);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 0753127..9a48a68 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -27,7 +27,7 @@
         new ChangeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, indexes, null, null, null, null, null, null, null));
+            null, null, null, null, indexes, null, null, null, null, null, null, null, null));
   }
 
   @Operator
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index c887875..6f40680 100644
--- a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -163,34 +163,37 @@
     // Not stale.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P2, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P2, RefState.create(ref2, id2.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isFalse();
 
     // Wrong ref value.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, SHA1),
-                    P2, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, SHA1),
+                        P2, RefState.create(ref2, id2.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isTrue();
 
     // Swapped repos.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id2.name()),
-                    P2, RefState.create(ref2, id1.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id2.name()),
+                        P2, RefState.create(ref2, id1.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isTrue();
 
     // Two refs in same repo, not stale.
@@ -199,32 +202,35 @@
     tr1.update(ref3, id3);
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, id3.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P1, RefState.create(ref3, id3.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isFalse();
 
     // Ignore ref not mentioned.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isFalse();
 
     // One ref wrong.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, SHA1)),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P1, RefState.create(ref3, SHA1)),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isTrue();
   }
 
@@ -236,10 +242,11 @@
     // ref1 is only ref matching pattern.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*")))
+                .isStale())
         .isFalse();
 
     // Now ref2 matches pattern, so stale unless ref2 is present in state map.
@@ -247,19 +254,21 @@
     ObjectId id2 = tr1.update(ref2, tr1.commit().message("commit 2"));
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*")))
+                .isStale())
         .isTrue();
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P1, RefState.create(ref2, id2.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*")))
+                .isStale())
         .isFalse();
   }
 
@@ -272,10 +281,11 @@
     // ref1 is only ref matching pattern.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo")))
+                .isStale())
         .isFalse();
 
     // Now ref2 matches pattern, so stale unless ref2 is present in state map.
@@ -283,19 +293,21 @@
     ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo")))
+                .isStale())
         .isTrue();
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, id3.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P1, RefState.create(ref3, id3.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo")))
+                .isStale())
         .isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 5993206..145e914 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -3062,6 +3062,38 @@
     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 {
     ObjectId dataId = notes.revisionNoteMap.noteMap.getNote(noteId).getData();
     return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
diff --git a/package.json b/package.json
index 6b9a38d..8fb3d3d 100644
--- a/package.json
+++ b/package.json
@@ -4,9 +4,9 @@
   "description": "Gerrit Code Review",
   "dependencies": {},
   "devDependencies": {
-    "eslint": "^5.16.0",
+    "eslint": "^6.6.0",
     "eslint-config-google": "^0.13.0",
-    "eslint-plugin-html": "^5.0.5",
+    "eslint-plugin-html": "^6.0.0",
     "fried-twinkie": "^0.2.2",
     "polylint": "^2.10.4",
     "typescript": "^2.x.x",
@@ -16,6 +16,7 @@
     "start": "polygerrit-ui/run-server.sh",
     "test": "WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh",
     "eslint": "./node_modules/eslint/bin/eslint.js --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js polygerrit-ui/app || exit 0",
+    "eslintfix": "./node_modules/eslint/bin/eslint.js --fix --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js polygerrit-ui/app || exit 0",
     "test-template": "./polygerrit-ui/app/run_template_test.sh",
     "polylint": "bazel test polygerrit-ui/app:polylint_test"
   },
diff --git a/plugins/hooks b/plugins/hooks
index 472a14f..71cdc88 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 472a14fb2dacbccb5fa9644b935c65f6898e41f0
+Subproject commit 71cdc88f4804a4811e613d8679862cb33dd75cc1
diff --git a/plugins/replication b/plugins/replication
index 94a465e..4786c07 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 94a465e0989ff8124aca3dca8e200aeb870cc9dd
+Subproject commit 4786c07027a0040810ae3e6a517f737cb57e1283
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
index 97151f2..0b3febe 100644
--- a/polygerrit-ui/app/.eslintrc.json
+++ b/polygerrit-ui/app/.eslintrc.json
@@ -25,27 +25,35 @@
     "block-spacing": ["error", "always"],
     "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
     "camelcase": "off",
-    "comma-dangle": ["error", "always-multiline"],
+    "comma-dangle": ["error", {
+      "arrays": "always-multiline",
+      "objects": "always-multiline",
+      "imports": "always-multiline",
+      "exports": "always-multiline",
+      "functions": "never"
+    }],
     "eol-last": "off",
-    "indent": "off",
-    "indent-legacy": ["error", 2, {
+    "indent": ["error", 2, {
       "MemberExpression": 2,
       "FunctionDeclaration": {"body": 1, "parameters": 2},
       "FunctionExpression": {"body": 1, "parameters": 2},
-      "CallExpression": {"arguments": 2},
+      "CallExpression": {"arguments": 2 },
       "ArrayExpression": 1,
       "ObjectExpression": 1,
       "SwitchCase": 1
     }],
     "keyword-spacing": ["error", { "after": true, "before": true }],
+    "lines-between-class-members": ["error", "always"],
     "max-len": [
       "error",
       80,
       2,
       {"ignoreComments": true}
     ],
-    "new-cap": ["error", { "capIsNewExceptions": ["Polymer"] }],
+    "new-cap": ["error", { "capIsNewExceptions": ["Polymer", "LegacyElementMixin", "GestureEventListeners", "LegacyDataMixin"] }],
     "no-console": "off",
+    "no-prototype-builtins": "off",
+    "no-redeclare": "off",
     "no-restricted-syntax": [
       "error",
       {
@@ -61,6 +69,19 @@
     "no-useless-escape": "off",
     "no-var": "error",
     "object-shorthand": ["error", "always"],
+    "padding-line-between-statements": [
+      "error",
+      {
+        "blankLine": "always",
+        "prev": "class",
+        "next": "*"
+      },
+      {
+        "blankLine": "always",
+        "prev": "*",
+        "next": "class"
+      }
+    ],
     "prefer-arrow-callback": "error",
     "prefer-const": "error",
     "prefer-spread": "error",
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
index 64b725f..4e25530 100644
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
@@ -56,7 +56,7 @@
       cachedPromise = undefined;
     },
   },
-    Gerrit.BaseUrlBehavior,
+  Gerrit.BaseUrlBehavior,
   ];
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
index f251db8..b6edb57 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
@@ -56,8 +56,8 @@
       return 0;
     },
   },
-    Gerrit.BaseUrlBehavior,
-    Gerrit.URLEncodingBehavior,
+  Gerrit.BaseUrlBehavior,
+  Gerrit.URLEncodingBehavior,
   ];
 })(window);
 </script>
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 5e4e8fd..5942833 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
@@ -124,8 +124,8 @@
       // 2 -> 3, 3 -> 5, etc.
       // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
       const num = r => r._number === Gerrit.PatchSetBehavior.EDIT_NAME ?
-          2 * editParent :
-          2 * (r._number - 1) + 1;
+        2 * editParent :
+        2 * (r._number - 1) + 1;
       return revisions.sort((a, b) => num(b) - num(a));
     },
 
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 5a563ab..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
@@ -240,8 +240,8 @@
 
         test('directory view', () => {
           const {
-              NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
-              SAVE_COMMENT,
+            NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
+            SAVE_COMMENT,
           } = kb.Shortcut;
           const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
           const {GO_KEY, ShortcutManager} = kb;
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 4252e6e..fbeaa64 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
@@ -175,7 +175,7 @@
       return this.changeStatuses(change).join(', ');
     },
   },
-    Gerrit.BaseUrlBehavior,
+  Gerrit.BaseUrlBehavior,
   ];
 })(window);
 </script>
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 b6f1b74..afe3955 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
@@ -224,8 +224,8 @@
 
     editRefInput() {
       return Polymer.dom(this.root).querySelector(Polymer.Element ?
-          'iron-input.editRefInput' :
-          'input[is=iron-input].editRefInput');
+        'iron-input.editRefInput' :
+        'input[is=iron-input].editRefInput');
     },
 
     editReference() {
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 5110dc1..c5fa827 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
@@ -206,8 +206,9 @@
           id: 'administrateServer',
           value: {},
         };
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
+        assert.equal(
+            element._computePermissionName(name, permission,
+                element.permissionValues, element.capabilities),
             element.capabilities[permission.id].name);
 
         name = 'refs/for/*';
@@ -216,8 +217,9 @@
           value: {},
         };
 
-        assert.equal(element._computePermissionName(
-            name, permission, element.permissionValues, element.capabilities),
+        assert.equal(
+            element._computePermissionName(
+                name, permission, element.permissionValues, element.capabilities),
             element.permissionValues[permission.id].name);
 
         name = 'refs/for/*';
@@ -228,8 +230,9 @@
           },
         };
 
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
+        assert.equal(
+            element._computePermissionName(name, permission,
+                element.permissionValues, element.capabilities),
             'Label Code-Review');
 
         permission = {
@@ -239,8 +242,9 @@
           },
         };
 
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
+        assert.equal(
+            element._computePermissionName(name, permission,
+                element.permissionValues, element.capabilities),
             'Label Code-Review(On Behalf Of)');
       });
 
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 4c099d6..74dcb87 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
@@ -120,11 +120,11 @@
               return;
             }
             this._groups = Object.keys(groups)
-             .map(key => {
-               const group = groups[key];
-               group.name = key;
-               return group;
-             });
+                .map(key => {
+                  const group = groups[key];
+                  group.name = key;
+                  return group;
+                });
             this._loading = false;
           });
     },
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 d3f020d..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
@@ -105,23 +105,23 @@
             .then(res => {
               this._filteredLinks = res.links;
               this._breadcrumbParentName = res.expandedSection ?
-                  res.expandedSection.name : '';
+                res.expandedSection.name : '';
 
               if (!res.expandedSection) {
                 this._subsectionLinks = [];
                 return;
               }
               this._subsectionLinks = [res.expandedSection]
-              .concat(res.expandedSection.children).map(section => {
-                return {
-                  text: !section.detailType ? 'Home' : section.name,
-                  value: section.view + (section.detailType || ''),
-                  view: section.view,
-                  url: section.url,
-                  detailType: section.detailType,
-                  parent: this._groupId || this._repoName || '',
-                };
-              });
+                  .concat(res.expandedSection.children).map(section => {
+                    return {
+                      text: !section.detailType ? 'Home' : section.name,
+                      value: section.view + (section.detailType || ''),
+                      view: section.view,
+                      url: section.url,
+                      detailType: section.detailType,
+                      parent: this._groupId || this._repoName || '',
+                    };
+                  });
             });
       });
     },
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 5e4d102..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
@@ -107,21 +107,21 @@
       }
       return this.$.restAPI.getRepoBranches(
           input, this.repoName, SUGGESTIONS_LIMIT).then(response => {
-            const branches = [];
-            let branch;
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              if (response[key].ref.startsWith('refs/heads/')) {
-                branch = response[key].ref.substring('refs/heads/'.length);
-              } else {
-                branch = response[key].ref;
-              }
-              branches.push({
-                name: branch,
-              });
-            }
-            return branches;
+        const branches = [];
+        let branch;
+        for (const key in response) {
+          if (!response.hasOwnProperty(key)) { continue; }
+          if (response[key].ref.startsWith('refs/heads/')) {
+            branch = response[key].ref.substring('refs/heads/'.length);
+          } else {
+            branch = response[key].ref;
+          }
+          branches.push({
+            name: branch,
           });
+        }
+        return branches;
+      });
     },
 
     _formatBooleanString(config) {
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 958c0ac..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
@@ -141,15 +141,15 @@
     _handleSavingGroupMember() {
       return this.$.restAPI.saveGroupMembers(this._groupName,
           this._groupMemberSearchId).then(config => {
-            if (!config) {
-              return;
-            }
-            this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-              this._groupMembers = members;
-            });
-            this._groupMemberSearchName = '';
-            this._groupMemberSearchId = '';
-          });
+        if (!config) {
+          return;
+        }
+        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+          this._groupMembers = members;
+        });
+        this._groupMemberSearchName = '';
+        this._groupMemberSearchId = '';
+      });
     },
 
     _handleDeleteConfirm() {
@@ -239,24 +239,24 @@
       if (input.length === 0) { return Promise.resolve([]); }
       return this.$.restAPI.getSuggestedAccounts(
           input, SUGGESTIONS_LIMIT).then(accounts => {
-            const accountSuggestions = [];
-            let nameAndEmail;
-            if (!accounts) { return []; }
-            for (const key in accounts) {
-              if (!accounts.hasOwnProperty(key)) { continue; }
-              if (accounts[key].email !== undefined) {
-                nameAndEmail = accounts[key].name +
+        const accountSuggestions = [];
+        let nameAndEmail;
+        if (!accounts) { return []; }
+        for (const key in accounts) {
+          if (!accounts.hasOwnProperty(key)) { continue; }
+          if (accounts[key].email !== undefined) {
+            nameAndEmail = accounts[key].name +
                   ' <' + accounts[key].email + '>';
-              } else {
-                nameAndEmail = accounts[key].name;
-              }
-              accountSuggestions.push({
-                name: nameAndEmail,
-                value: accounts[key]._account_id,
-              });
-            }
-            return accountSuggestions;
+          } else {
+            nameAndEmail = accounts[key].name;
+          }
+          accountSuggestions.push({
+            name: nameAndEmail,
+            value: accounts[key]._account_id,
           });
+        }
+        return accountSuggestions;
+      });
     },
 
     _getGroupSuggestions(input) {
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 15a59c8..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
@@ -169,10 +169,10 @@
           .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
       assert.equal(Polymer.dom(element.root)
           .querySelectorAll('.nameColumn a')[1].href,
-          'https://test/site/group/url');
+      'https://test/site/group/url');
       assert.equal(Polymer.dom(element.root)
           .querySelectorAll('.nameColumn a')[2].href,
-          'https://test/site/group/url');
+      'https://test/site/group/url');
     });
 
     test('save members correctly', () => {
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 68228b4..19cb45c 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
@@ -172,15 +172,15 @@
       }
       return this.$.restAPI.saveGroupOwner(this.groupId,
           owner).then(config => {
-            this._owner = false;
-          });
+        this._owner = false;
+      });
     },
 
     _handleSaveDescription() {
       return this.$.restAPI.saveGroupDescription(this.groupId,
           this._groupConfig.description).then(config => {
-            this._description = false;
-          });
+        this._description = false;
+      });
     },
 
     _handleSaveOptions() {
@@ -190,8 +190,8 @@
 
       return this.$.restAPI.saveGroupOptions(this.groupId,
           options).then(config => {
-            this._options = false;
-          });
+        this._options = false;
+      });
     },
 
     _handleConfigName() {
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 b31aee6..c485b15 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -228,7 +228,7 @@
 
     _computeGroupName(groups, groupId) {
       return groups && groups[groupId] && groups[groupId].name ?
-          groups[groupId].name : groupId;
+        groups[groupId].name : groupId;
     },
 
     _getGroupSuggestions() {
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 b77f078..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
@@ -92,11 +92,11 @@
               return;
             }
             this._plugins = Object.keys(plugins)
-             .map(key => {
-               const plugin = plugins[key];
-               plugin.name = key;
-               return plugin;
-             });
+                .map(key => {
+                  const plugin = plugins[key];
+                  plugin.name = key;
+                  return plugin;
+                });
             this._loading = false;
           });
     },
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 94bef54..18cd790 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
@@ -167,7 +167,7 @@
             // current value appears. If there is no parent repo, it is
             // initialized as an empty string.
             this._inheritFromFilter = res.inherits_from ?
-                this._inheritsFrom.name : '';
+              this._inheritsFrom.name : '';
             this._local = res.local;
             this._groups = res.groups;
             this._weblinks = res.config_web_links || [];
@@ -370,11 +370,11 @@
       };
 
       const originalInheritsFromId = this._originalInheritsFrom ?
-          this.singleDecodeURL(this._originalInheritsFrom.id) :
-          null;
+        this.singleDecodeURL(this._originalInheritsFrom.id) :
+        null;
       const inheritsFromId = this._inheritsFrom ?
-          this.singleDecodeURL(this._inheritsFrom.id) :
-          null;
+        this.singleDecodeURL(this._inheritsFrom.id) :
+        null;
 
       const inheritFromChanged =
           // Inherit from changed
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 8b1de6e..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
@@ -676,7 +676,7 @@
             Polymer.dom(element.$$('gr-access-section').root).querySelectorAll(
                 'gr-permission')[2];
         newPermission._handleAddRuleItem(
-           {detail: {value: {id: 'Maintainers'}}});
+            {detail: {value: {id: 'Maintainers'}}});
         assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
         // Modify a section reference.
@@ -909,7 +909,7 @@
             Polymer.dom(element.$$('gr-access-section').root).querySelectorAll(
                 'gr-permission')[1];
         readPermission._handleAddRuleItem(
-           {detail: {value: {id: 'Maintainers'}}});
+            {detail: {value: {id: 'Maintainers'}}});
 
         expectedInput = {
           add: {
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 92f15b0..95faaf8 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
@@ -101,17 +101,17 @@
     _handleEditRepoConfig() {
       return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
           EDIT_CONFIG_SUBJECT, undefined, false, true).then(change => {
-            const message = change ?
-                CREATE_CHANGE_SUCCEEDED_MESSAGE :
-                CREATE_CHANGE_FAILED_MESSAGE;
-            this.dispatchEvent(new CustomEvent(
-                'show-alert',
-                {detail: {message}, bubbles: true, composed: true}));
-            if (!change) { return; }
+        const message = change ?
+          CREATE_CHANGE_SUCCEEDED_MESSAGE :
+          CREATE_CHANGE_FAILED_MESSAGE;
+        this.dispatchEvent(new CustomEvent(
+            'show-alert',
+            {detail: {message}, bubbles: true, composed: true}));
+        if (!change) { return; }
 
-            Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getEditUrlForDiff(
-                change, CONFIG_PATH, INITIAL_PATCHSET));
-          });
+        Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getEditUrlForDiff(
+            change, CONFIG_PATH, INITIAL_PATCHSET));
+      });
     },
   });
 })();
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 6e38566..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
@@ -49,7 +49,7 @@
 
         // Group by ref and sort by id.
         const dashboards = res.concat.apply([], res).sort((a, b) =>
-            a.id < b.id ? -1 : 1);
+          a.id < b.id ? -1 : 1);
         const dashboardsByRef = {};
         dashboards.forEach(d => {
           if (!dashboardsByRef[d.ref]) {
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 da8ef52..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
@@ -91,7 +91,7 @@
     _determineIfOwner(repo) {
       return this.$.restAPI.getRepoAccess(repo)
           .then(access =>
-                this._isOwner = access && !!access[repo].is_owner);
+            this._isOwner = access && !!access[repo].is_owner);
     },
 
     _paramsChanged(params) {
@@ -125,17 +125,17 @@
       if (detailType === DETAIL_TYPES.BRANCHES) {
         return this.$.restAPI.getRepoBranches(
             filter, repo, itemsPerPage, offset, errFn).then(items => {
-              if (!items) { return; }
-              this._items = items;
-              this._loading = false;
-            });
+          if (!items) { return; }
+          this._items = items;
+          this._loading = false;
+        });
       } else if (detailType === DETAIL_TYPES.TAGS) {
         return this.$.restAPI.getRepoTags(
             filter, repo, itemsPerPage, offset, errFn).then(items => {
-              if (!items) { return; }
-              this._items = items;
-              this._loading = false;
-            });
+          if (!items) { return; }
+          this._items = items;
+          this._loading = false;
+        });
       }
     },
 
@@ -174,7 +174,7 @@
 
     _computeCanEditClass(ref, detailType, isOwner) {
       return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
-          'canEdit' : '';
+        'canEdit' : '';
     },
 
     _handleEditRevision(e) {
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 d8d4f7c..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
@@ -155,9 +155,9 @@
         const cancelBtn = Polymer.dom(element.root).querySelector('.cancelBtn');
         const editBtn = Polymer.dom(element.root).querySelector('.editBtn');
         const revisionNoEditing = Polymer.dom(element.root)
-              .querySelector('.revisionNoEditing');
+            .querySelector('.revisionNoEditing');
         const revisionWithEditing = Polymer.dom(element.root)
-              .querySelector('.revisionWithEditing');
+            .querySelector('.revisionWithEditing');
 
         sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
         sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
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 52db4c2..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
@@ -29,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]]"
@@ -41,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>
@@ -55,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">
@@ -64,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-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 07da7c7..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
@@ -54,7 +54,7 @@
           {base: {config: {}}}), []);
       assert.deepEqual(element._computePluginConfigOptions(
           {base: {config: {testKey: 'testInfo'}}}),
-          [{_key: 'testKey', info: 'testInfo'}]);
+      [{_key: 'testKey', info: 'testInfo'}]);
     });
 
     test('_computeDisabled', () => {
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 918efba..153a137 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -283,8 +283,8 @@
     _handleSaveRepoConfig() {
       return this.$.restAPI.saveRepoConfig(this.repo,
           this._formatRepoConfigForSave(this._repoConfig)).then(() => {
-            this._configChanged = false;
-          });
+        this._configChanged = false;
+      });
     },
 
     _handleConfigChanged() {
@@ -327,7 +327,7 @@
           command: commandObj[title]
               .replace(/\$\{project\}/gi, encodeURI(repo))
               .replace(/\$\{project-base-name\}/gi,
-              encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
+                  encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
         });
       }
       return commands;
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 f22c5a5..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
@@ -367,7 +367,7 @@
           element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
               configInputObj.match_author_to_committer_date;
           const inputElement = Polymer.Element ?
-              element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+            element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
           inputElement.bindValue = configInputObj.max_object_size_limit;
           element.$.contributorAgreementSelect.bindValue =
               configInputObj.use_contributor_agreements;
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 4ea3817..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
@@ -125,7 +125,7 @@
         let permission = 'priority';
         let label;
         assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'BATCH'});
+            {action: 'BATCH'});
         permission = 'label-Code-Review';
         label = {values: [
           {value: -2, text: 'This shall not be merged'},
@@ -139,7 +139,7 @@
         permission = 'push';
         label = undefined;
         assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', force: false});
+            {action: 'ALLOW', force: false});
         permission = 'submit';
         assert.deepEqual(element._getDefaultRuleValues(permission, label),
             {action: 'ALLOW'});
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 1cddbc5..12b4202 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
@@ -57,33 +57,34 @@
           'cell label u-gray-background');
       assert.equal(element._computeLabelClass(
           {labels: {}}, 'Verified'), 'cell label u-gray-background');
-      assert.equal(element._computeLabelClass(
-          {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+      assert.equal(
+          element._computeLabelClass(
+              {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
           'cell label u-green u-monospace');
       assert.equal(element._computeLabelClass(
           {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
-          'cell label u-monospace u-red');
+      'cell label u-monospace u-red');
       assert.equal(element._computeLabelClass(
           {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
-          'cell label u-green u-monospace');
+      'cell label u-green u-monospace');
       assert.equal(element._computeLabelClass(
           {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
-          'cell label u-monospace u-red');
+      'cell label u-monospace u-red');
       assert.equal(element._computeLabelClass(
           {labels: {'Code-Review': {value: -1}}}, 'Verified'),
-          'cell label u-gray-background');
+      'cell label u-gray-background');
 
       assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
           'Label not applicable');
       assert.equal(element._computeLabelTitle(
           {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
-          'Verified\nby Diffy');
+      'Verified\nby Diffy');
       assert.equal(element._computeLabelTitle(
           {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
-          'Label not applicable');
+      'Label not applicable');
       assert.equal(element._computeLabelTitle(
           {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
-          'Verified\nby Diffy');
+      'Verified\nby Diffy');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
           'Code-Review'), 'Code-Review\nby Diffy');
@@ -93,19 +94,19 @@
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {recommended: {name: 'Diffy'},
             rejected: {name: 'Admin'}}}}, 'Code-Review'),
-          'Code-Review\nby Admin');
+      'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {approved: {name: 'Diffy'},
             rejected: {name: 'Admin'}}}}, 'Code-Review'),
-          'Code-Review\nby Admin');
+      'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {recommended: {name: 'Diffy'},
             disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-          'Code-Review\nby Admin');
+      'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {approved: {name: 'Diffy'},
             disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-          'Code-Review\nby Diffy');
+      'Code-Review\nby Diffy');
 
       assert.equal(element._computeLabelValue({labels: {}}), '');
       assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
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 e32c773..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
@@ -169,7 +169,7 @@
         this.showNumber = !!(preferences &&
             preferences.legacycid_in_change_table);
         this.visibleChangeTableColumns = preferences.change_table.length > 0 ?
-            this.getVisibleColumns(preferences.change_table) : this.columnNames;
+          this.getVisibleColumns(preferences.change_table) : this.columnNames;
       } else {
         // Not logged in.
         this.showNumber = false;
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 87b1665..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
@@ -113,7 +113,7 @@
 
     test('computed fields', () => {
       assert.equal(element._computeLabelNames(
-            [{results: [{_number: 0, labels: {}}]}]).length, 0);
+          [{results: [{_number: 0, labels: {}}]}]).length, 0);
       assert.equal(element._computeLabelNames([
         {results: [
           {_number: 0, labels: {Verified: {approved: {}}}},
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 aab0cba..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
@@ -109,21 +109,21 @@
       };
       return this.$.restAPI.getDashboard(
           project, dashboard, errFn).then(response => {
-            if (!response) {
-              return;
-            }
+        if (!response) {
+          return;
+        }
+        return {
+          title: response.title,
+          sections: response.sections.map(section => {
+            const suffix = response.foreach ? ' ' + response.foreach : '';
             return {
-              title: response.title,
-              sections: response.sections.map(section => {
-                const suffix = response.foreach ? ' ' + response.foreach : '';
-                return {
-                  name: section.name,
-                  query: (section.query + suffix).replace(
-                      PROJECT_PLACEHOLDER_PATTERN, project),
-                };
-              }),
+              name: section.name,
+              query: (section.query + suffix).replace(
+                  PROJECT_PLACEHOLDER_PATTERN, project),
             };
-          });
+          }),
+        };
+      });
     },
 
     _computeTitle(user) {
@@ -156,11 +156,11 @@
       this._loading = true;
       const {project, dashboard, title, user, sections} = this.params;
       const dashboardPromise = project ?
-          this._getProjectDashboard(project, dashboard) :
-          Promise.resolve(Gerrit.Nav.getUserDashboard(
-              user,
-              sections,
-              title || this._computeTitle(user)));
+        this._getProjectDashboard(project, dashboard) :
+        Promise.resolve(Gerrit.Nav.getUserDashboard(
+            user,
+            sections,
+            title || this._computeTitle(user)));
 
       const checkForNewUser = !project && user === 'self';
       return dashboardPromise
@@ -194,8 +194,8 @@
 
       const queries = res.sections
           .map(section => section.suffixForDashboard ?
-              section.query + ' ' + section.suffixForDashboard :
-              section.query);
+            section.query + ' ' + section.suffixForDashboard :
+            section.query);
 
       if (checkForNewUser) {
         queries.push('owner:self limit:1');
@@ -215,7 +215,7 @@
               results,
               isOutgoing: res.sections[i].isOutgoing,
             })).filter((section, i) => i < res.sections.length && (
-                !res.sections[i].hideIfEmpty ||
+              !res.sections[i].hideIfEmpty ||
                 section.results.length));
           });
     },
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 6942705..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
@@ -89,7 +89,7 @@
 
     _computeDashboardLinkClass(showDashboardLink, loggedIn) {
       return showDashboardLink && loggedIn ?
-          'dashboardLink' : 'dashboardLink hide';
+        'dashboardLink' : 'dashboardLink hide';
     },
   });
 })();
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 46b79b1..4783509 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
@@ -36,6 +36,7 @@
 <link rel="import" href="../gr-confirm-move-dialog/gr-confirm-move-dialog.html">
 <link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html">
 <link rel="import" href="../gr-confirm-revert-dialog/gr-confirm-revert-dialog.html">
+<link rel="import" href="../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html">
 <link rel="import" href="../gr-confirm-submit-dialog/gr-confirm-submit-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -208,6 +209,11 @@
           on-confirm="_handleRevertDialogConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-revert-dialog>
+      <gr-confirm-revert-submission-dialog id="confirmRevertSubmissionDialog"
+          class="confirmDialog"
+          on-confirm="_handleRevertSubmissionDialogConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          hidden></gr-confirm-revert-submission-dialog>
       <gr-confirm-abandon-dialog id="confirmAbandonDialog"
           class="confirmDialog"
           on-confirm="_handleAbandonDialogConfirm"
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 fe70239..0158627 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
@@ -64,6 +64,7 @@
     REBASE_EDIT: 'rebaseEdit',
     RESTORE: 'restore',
     REVERT: 'revert',
+    REVERT_SUBMISSION: 'revert_submission',
     REVIEWED: 'reviewed',
     STOP_EDIT: 'stopEdit',
     UNIGNORE: 'unignore',
@@ -86,6 +87,7 @@
     rebase: 'Rebasing...',
     restore: 'Restoring...',
     revert: 'Reverting...',
+    revert_submission: 'Reverting Submission...',
     submit: 'Submitting...',
   };
 
@@ -180,6 +182,7 @@
     ChangeActions.REBASE_EDIT,
     ChangeActions.RESTORE,
     ChangeActions.REVERT,
+    ChangeActions.REVERT_SUBMISSION,
     ChangeActions.STOP_EDIT,
     QUICK_APPROVE_ACTION.key,
     RevisionActions.REBASE,
@@ -434,7 +437,7 @@
 
     _getRebaseAction(revisionActions) {
       return this._getRevisionAction(revisionActions, 'rebase',
-        {rebaseOnCurrent: null}
+          {rebaseOnCurrent: null}
       );
     },
 
@@ -625,7 +628,7 @@
       }
     },
 
-      /**
+    /**
        * @param {string=} actionName
        */
     _deleteAndNotify(actionName) {
@@ -906,6 +909,19 @@
       this._showActionDialog(this.$.confirmRevertDialog);
     },
 
+    _modifyRevertSubmissionMsg() {
+      return this.$.jsAPI.modifyRevertSubmissionMsg(this.change,
+          this.$.confirmRevertSubmissionDialog.message, this.commitMessage);
+    },
+
+    showRevertSubmissionDialog() {
+      this.$.confirmRevertSubmissionDialog.populateRevertSubmissionMessage(
+          this.commitMessage, this.change.current_revision);
+      this.$.confirmRevertSubmissionDialog.message =
+          this._modifyRevertSubmissionMsg();
+      this._showActionDialog(this.$.confirmRevertSubmissionDialog);
+    },
+
     _handleActionTap(e) {
       e.preventDefault();
       let el = Polymer.dom(e).localTarget;
@@ -956,6 +972,9 @@
         case ChangeActions.REVERT:
           this.showRevertDialog();
           break;
+        case ChangeActions.REVERT_SUBMISSION:
+          this.showRevertSubmissionDialog();
+          break;
         case ChangeActions.ABANDON:
           this._showActionDialog(this.$.confirmAbandonDialog);
           break;
@@ -1118,6 +1137,14 @@
           {message: el.message});
     },
 
+    _handleRevertSubmissionDialogConfirm() {
+      const el = this.$.confirmRevertSubmissionDialog;
+      this.$.overlay.close();
+      el.hidden = true;
+      this._fireAction('/revert_submission', this.actions.revert_submission,
+          false, {message: el.message});
+    },
+
     _handleAbandonDialogConfirm() {
       const el = this.$.confirmAbandonDialog;
       this.$.overlay.close();
@@ -1448,9 +1475,9 @@
 
     _filterPrimaryActions(_topLevelActions) {
       this._topLevelPrimaryActions = _topLevelActions.filter(action =>
-          action.__primary);
+        action.__primary);
       this._topLevelSecondaryActions = _topLevelActions.filter(action =>
-          !action.__primary);
+        !action.__primary);
     },
 
     _computeMenuActions(actionRecord, hiddenActionsRecord) {
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 37201ac..c686127 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
@@ -378,7 +378,7 @@
         element._handleRebaseConfirm({detail: {base: '1234'}});
         rebaseAction.rebaseOnCurrent = true;
         assert.deepEqual(fireActionStub.lastCall.args,
-          ['/rebase', rebaseAction, true, {base: '1234'}]);
+            ['/rebase', rebaseAction, true, {base: '1234'}]);
         done();
       });
     });
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 86f8aaf..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
@@ -89,6 +89,7 @@
 
     teardown(() => {
       sandbox.restore();
+      Gerrit._testOnly_resetPlugins();
     });
 
     suite('by default', () => {
@@ -113,7 +114,7 @@
             js_resource_paths: [],
             html_resource_paths: [
               new URL('test/plugin.html?' + Math.random(),
-                      window.location.href).toString(),
+                  window.location.href).toString(),
             ],
           },
         };
@@ -139,9 +140,9 @@
       setup(() => {
         Gerrit.install(p => plugin = p, '0.1',
             new URL('test/plugin.html?' + Math.random(),
-                    window.location.href).toString());
+                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.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 07eb59e..310a1a6 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
@@ -228,12 +228,12 @@
       this._newHashtag = '';
       this.$.restAPI.setChangeHashtag(
           this.change._number, {add: [newHashtag]}).then(newHashtag => {
-            this.set(['change', 'hashtags'], newHashtag);
-            if (newHashtag !== lastHashtag) {
-              this.dispatchEvent(new CustomEvent(
-                  'hashtag-changed', {bubbles: true, composed: true}));
-            }
-          });
+        this.set(['change', 'hashtags'], newHashtag);
+        if (newHashtag !== lastHashtag) {
+          this.dispatchEvent(new CustomEvent(
+              'hashtag-changed', {bubbles: true, composed: true}));
+        }
+      });
     },
 
     _computeTopicReadOnly(mutable, change) {
@@ -344,7 +344,7 @@
       if (!this.change || !this.change.status) return '';
       return Gerrit.Nav.getUrlForBranch(branch, project,
           this.change.status == this.ChangeStatus.NEW ? 'open' :
-              this.change.status.toLowerCase());
+            this.change.status.toLowerCase());
     },
 
     _computeTopicURL(topic) {
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 fe09869..6f06fc8 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
@@ -426,7 +426,7 @@
           {current_revision: '789', revisions: {456: {commit: {parents}}}}));
       assert.equal(element._computeParents(
           {current_revision: '456', revisions: {456: {commit: {parents}}}}),
-          parents);
+      parents);
     });
 
     test('_computeParentsLabel', () => {
@@ -725,7 +725,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-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index 8ec00dd..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
@@ -135,8 +135,8 @@
 
     _computeShowHideIcon(showOptionalLabels) {
       return showOptionalLabels ?
-          'gr-icons:expand-less' :
-          'gr-icons:expand-more';
+        'gr-icons:expand-less' :
+        'gr-icons:expand-more';
     },
 
     _computeSectionClass(show) {
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 e6ceea2..a293551 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
@@ -184,7 +184,7 @@
         computed:
           '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
       },
-        /** @type {?} */
+      /** @type {?} */
       _patchRange: {
         type: Object,
       },
@@ -456,16 +456,16 @@
       this.$.commitMessageEditor.disabled = true;
       this.$.restAPI.putChangeCommitMessage(
           this._changeNum, message).then(resp => {
-            this.$.commitMessageEditor.disabled = false;
-            if (!resp.ok) { return; }
+        this.$.commitMessageEditor.disabled = false;
+        if (!resp.ok) { return; }
 
-            this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-                message);
-            this._editingCommitMessage = false;
-            this._reloadWindow();
-          }).catch(err => {
-            this.$.commitMessageEditor.disabled = false;
-          });
+        this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+            message);
+        this._editingCommitMessage = false;
+        this._reloadWindow();
+      }).catch(err => {
+        this.$.commitMessageEditor.disabled = false;
+      });
     },
 
     _reloadWindow() {
@@ -819,7 +819,7 @@
 
     _viewStateChanged(viewState) {
       this._numFilesShown = viewState.numFilesShown ?
-          viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
+        viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
     },
 
     _numFilesShownChanged(numFilesShown) {
@@ -937,7 +937,7 @@
       // check that there is at least 2 parents otherwise fall back to 1,
       // which means there is only one parent.
       const parentCount = parentCounts.hasOwnProperty(1) ?
-          parentCounts[1] : 1;
+        parentCounts[1] : 1;
 
       const preferFirst = this._prefs &&
           this._prefs.default_base_for_merges === 'FIRST_PARENT';
@@ -1327,10 +1327,10 @@
     _getLatestCommitMessage() {
       return this.$.restAPI.getChangeCommitInfo(this._changeNum,
           this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
-            if (!commitInfo) return Promise.resolve();
-            this._latestCommitMessage =
+        if (!commitInfo) return Promise.resolve();
+        this._latestCommitMessage =
                     this._prepareCommitMsgForLinkify(commitInfo.message);
-          });
+      });
     },
 
     _getLatestRevisionSHA(change) {
@@ -1376,7 +1376,7 @@
             this._changeComments = comments;
             this._diffDrafts = Object.assign({}, this._changeComments.drafts);
             this._commentThreads = this._changeComments.getAllThreadsForChange()
-              .map(c => Object.assign({}, c));
+                .map(c => Object.assign({}, c));
           });
     },
 
@@ -1800,7 +1800,7 @@
      */
     _handleEditTap() {
       const editInfo = Object.values(this._change.revisions).find(info =>
-          info._number === this.EDIT_NAME);
+        info._number === this.EDIT_NAME);
 
       if (editInfo) {
         Gerrit.Nav.navigateToChange(this._change, this.EDIT_NAME);
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 f9c7798..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
@@ -81,7 +81,7 @@
       });
       element = fixture('basic');
       sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
-      Gerrit._setPluginsCount(0);
+      Gerrit._loadPlugins([]);
     });
 
     teardown(done => {
@@ -176,7 +176,7 @@
           assert.isFalse(element.$.replyOverlay.opened);
           assert(openSpy.lastCall.calledWithExactly(
               element.$.replyDialog.FocusTarget.ANY),
-              '_openReplyDialog should have been passed ANY');
+          '_openReplyDialog should have been passed ANY');
           assert.equal(openSpy.callCount, 1);
           done();
         });
@@ -1046,7 +1046,7 @@
           MockInteractions.tap(element.$.replyBtn);
           assert(openStub.lastCall.calledWithExactly(
               element.$.replyDialog.FocusTarget.ANY),
-              '_openReplyDialog should have been passed ANY');
+          '_openReplyDialog should have been passed ANY');
           assert.equal(openStub.callCount, 1);
         });
 
@@ -1058,7 +1058,7 @@
             {message: {message: 'text'}});
         assert(openStub.lastCall.calledWithExactly(
             element.$.replyDialog.FocusTarget.BODY),
-            '_openReplyDialog should have been passed BODY');
+        '_openReplyDialog should have been passed BODY');
         assert.equal(openStub.callCount, 1);
         done();
       });
@@ -1483,7 +1483,7 @@
 
     test('_computeEditMode', () => {
       const callCompute = (range, params) =>
-          element._computeEditMode({base: range}, {base: params});
+        element._computeEditMode({base: range}, {base: params});
       assert.isFalse(callCompute({}, {}));
       assert.isTrue(callCompute({}, {edit: true}));
       assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
@@ -1703,7 +1703,7 @@
 
       element._patchRange = {patchNum: 1};
       element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
-            {bubbles: false}));
+          {bubbles: false}));
     });
 
     suite('plugin endpoints', () => {
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 cbc7e42..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
@@ -45,7 +45,7 @@
 
     _computeDiffLineURL(file, changeNum, patchNum, comment) {
       const basePatchNum = comment.hasOwnProperty('parent') ?
-          -comment.parent : null;
+        -comment.parent : null;
       return Gerrit.Nav.getUrlForDiffById(this.changeNum, this.projectName,
           file, patchNum, basePatchNum, comment.line,
           this._isOnParent(comment));
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 bf84003..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
@@ -98,21 +98,21 @@
       }
       return this.$.restAPI.getRepoBranches(
           input, this.project, SUGGESTIONS_LIMIT).then(response => {
-            const branches = [];
-            let branch;
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              if (response[key].ref.startsWith('refs/heads/')) {
-                branch = response[key].ref.substring('refs/heads/'.length);
-              } else {
-                branch = response[key].ref;
-              }
-              branches.push({
-                name: branch,
-              });
-            }
-            return branches;
+        const branches = [];
+        let branch;
+        for (const key in response) {
+          if (!response.hasOwnProperty(key)) { continue; }
+          if (response[key].ref.startsWith('refs/heads/')) {
+            branch = response[key].ref.substring('refs/heads/'.length);
+          } else {
+            branch = response[key].ref;
+          }
+          branches.push({
+            name: branch,
           });
+        }
+        return branches;
+      });
     },
   });
 })();
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 23123c3..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
@@ -68,21 +68,21 @@
       }
       return this.$.restAPI.getRepoBranches(
           input, this.project, SUGGESTIONS_LIMIT).then(response => {
-            const branches = [];
-            let branch;
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              if (response[key].ref.startsWith('refs/heads/')) {
-                branch = response[key].ref.substring('refs/heads/'.length);
-              } else {
-                branch = response[key].ref;
-              }
-              branches.push({
-                name: branch,
-              });
-            }
-            return branches;
+        const branches = [];
+        let branch;
+        for (const key in response) {
+          if (!response.hasOwnProperty(key)) { continue; }
+          if (response[key].ref.startsWith('refs/heads/')) {
+            branch = response[key].ref.substring('refs/heads/'.length);
+          } else {
+            branch = response[key].ref;
+          }
+          branches.push({
+            name: branch,
           });
+        }
+        return branches;
+      });
     },
   });
 })();
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 1b884e7..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
@@ -82,7 +82,7 @@
 
     _getChangeSuggestions(input) {
       return this._getRecentChanges().then(changes =>
-          this._filterChanges(input, changes));
+        this._filterChanges(input, changes));
     },
 
     _filterChanges(input, changes) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html
new file mode 100644
index 0000000..b334989
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html
@@ -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.
+-->
+
+<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">
+
+<dom-module id="gr-confirm-revert-submission-dialog">
+  <template>
+    <!-- TODO(taoalpha): move all shared styles to a style module. -->
+    <style include="shared-styles">
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        padding: 0;
+        width: 73ch; /* Add a char to account for the border. */
+
+        --iron-autogrow-textarea {
+          border: 1px solid var(--border-color);
+          box-sizing: border-box;
+          font-family: var(--monospace-font-family);
+        }
+      }
+    </style>
+    <gr-dialog
+        confirm-label="Revert Submission"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header" slot="header">Revert Submission</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>
+      </div>
+    </gr-dialog>
+  </template>
+  <script src="gr-confirm-revert-submission-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
new file mode 100644
index 0000000..6cbbb37
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
@@ -0,0 +1,69 @@
+/**
+ * @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';
+
+  const ERR_COMMIT_NOT_FOUND =
+      'Unable to find the commit hash of this change.';
+
+  Polymer({
+    is: 'gr-confirm-revert-submission-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      message: String,
+    },
+
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
+    populateRevertSubmissionMessage(message, commitHash) {
+      // Follow the same convention of the revert
+      const revertTitle = 'Revert submission';
+      if (!commitHash) {
+        this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
+        return;
+      }
+      this.message = `${revertTitle}\n\n` +
+          `Reason for revert: <INSERT REASONING HERE>\n`;
+    },
+
+    _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-submission-dialog/gr-confirm-revert-submission-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
new file mode 100644
index 0000000..c0f2dc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_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-confirm-revert-submission-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.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-submission-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-revert-submission-dialog>
+    </gr-confirm-revert-submission-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-revert-submission-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      element = fixture('basic');
+      sandbox =sinon.sandbox.create();
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('no match', () => {
+      assert.isNotOk(element.message);
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+      element.populateRevertSubmissionMessage(
+          'not a commitHash in sight'
+      );
+      assert.isTrue(alertStub.calledOnce);
+    });
+
+    test('single line', () => {
+      assert.isNotOk(element.message);
+      element.populateRevertSubmissionMessage(
+          'one line commit\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      const expected = 'Revert submission\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('multi line', () => {
+      assert.isNotOk(element.message);
+      element.populateRevertSubmissionMessage(
+          'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      const expected = 'Revert submission\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('issue above change id', () => {
+      assert.isNotOk(element.message);
+      element.populateRevertSubmissionMessage(
+          'test \nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+          'abcd123');
+      const expected = 'Revert submission\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('revert a revert', () => {
+      assert.isNotOk(element.message);
+      element.populateRevertSubmissionMessage(
+          'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      const expected = 'Revert submission\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+  });
+</script>
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 90cc60f..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
@@ -157,7 +157,7 @@
       for (const rev of Object.values(change.revisions || {})) {
         if (this.patchNumEquals(rev._number, patchNum)) {
           const parentLength = rev.commit && rev.commit.parents ?
-                rev.commit.parents.length : 0;
+            rev.commit.parents.length : 0;
           return parentLength == 0;
         }
       }
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 de02923..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
@@ -182,7 +182,7 @@
       test('computed fields', () => {
         assert.equal(element._computeArchiveDownloadLink(
             {project: 'test/project', _number: 123}, 2, 'tgz'),
-            '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
+        '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
       });
 
       test('close event', done => {
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 2126433..b8095bf 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
@@ -148,7 +148,7 @@
 
       const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
       this._patchsetDescription = (rev && rev.description) ?
-          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+        rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
     _handleDescriptionRemoved(e) {
@@ -257,7 +257,7 @@
     _computeUploadHelpContainerClass(change, account) {
       const changeIsMerged = change && change.status === MERGED_STATUS;
       const ownerId = change && change.owner && change.owner._account_id ?
-          change.owner._account_id : null;
+        change.owner._account_id : null;
       const userId = account && account._account_id;
       const userIsOwner = ownerId && userId && ownerId === userId;
       const hideContainer = !userIsOwner || changeIsMerged;
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 4903467..6c44694 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
@@ -839,7 +839,7 @@
 
     _computeShowHideIcon(path, expandedFilesRecord) {
       return this._isFileExpanded(path, expandedFilesRecord) ?
-          'gr-icons:expand-less' : 'gr-icons:expand-more';
+        'gr-icons:expand-less' : 'gr-icons:expand-more';
     },
 
     _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
@@ -879,7 +879,7 @@
       }
 
       const previousNumFilesShown = this._shownFiles ?
-          this._shownFiles.length : 0;
+        this._shownFiles.length : 0;
 
       const filesShown = files.slice(0, numFilesShown);
       this.fire('files-shown-changed', {length: filesShown.length});
@@ -954,7 +954,7 @@
 
       const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
-          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+        rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
     /**
@@ -966,7 +966,7 @@
     _computeFileStatusLabel(status) {
       const statusCode = this._computeFileStatus(status);
       return FileStatus.hasOwnProperty(statusCode) ?
-          FileStatus[statusCode] : 'Status Unknown';
+        FileStatus[statusCode] : 'Status Unknown';
     },
 
     _isFileExpanded(path, expandedFilesRecord) {
@@ -998,7 +998,7 @@
       // Clear content for any diffs that are not open so if they get re-opened
       // the stale content does not flash before it is cleared and reloaded.
       const collapsedDiffs = this.diffs.filter(diff =>
-          this._expandedFilePaths.indexOf(diff.path) === -1);
+        this._expandedFilePaths.indexOf(diff.path) === -1);
       this._clearCollapsedDiffs(collapsedDiffs);
 
       if (!record) { return; } // Happens after "Collapse all" clicked.
@@ -1008,9 +1008,9 @@
 
       // Find the paths introduced by the new index splices:
       const newPaths = record.indexSplices
-            .map(splice => splice.object.slice(
-                splice.index, splice.index + splice.addedCount))
-            .reduce((acc, paths) => acc.concat(paths), []);
+          .map(splice => splice.object.slice(
+              splice.index, splice.index + splice.addedCount))
+          .reduce((acc, paths) => acc.concat(paths), []);
 
       // Required so that the newly created diff view is included in this.diffs.
       Polymer.dom.flush();
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 122291e..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
@@ -440,10 +440,10 @@
               '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, parentTo1
-          , '/COMMIT_MSG'), '2c');
+              , '/COMMIT_MSG'), '2c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2
-          , '/COMMIT_MSG'), '3c');
+              , '/COMMIT_MSG'), '3c');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
               'unresolved.file'), '1 draft');
@@ -639,7 +639,7 @@
 
         assert(navStub.lastCall.calledWith(element.change,
             'file_added_in_rev2.txt', '2'),
-            'Should navigate to /c/42/2/file_added_in_rev2.txt');
+        'Should navigate to /c/42/2/file_added_in_rev2.txt');
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -1639,7 +1639,7 @@
 
       element.set('_filesByPath', _filesByPath);
       flushAsynchronousOperations();
-       // Navigates when a file is selected.
+      // Navigates when a file is selected.
       element._openSelectedFile();
       assert.isTrue(navStub.called);
     });
@@ -1701,7 +1701,7 @@
       const editControls =
           Array.from(
               Polymer.dom(element.root)
-              .querySelectorAll('.row:not(.header-row)'))
+                  .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-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 0b888c4..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
@@ -123,7 +123,7 @@
       if (!labels[label.name]) { return null; }
       const labelValue = this._getLabelValue(labels, permittedLabels, label);
       const len = permittedLabels[label.name] != null ?
-          permittedLabels[label.name].length : 0;
+        permittedLabels[label.name].length : 0;
       for (let i = 0; i < len; i++) {
         const val = permittedLabels[label.name][i];
         if (val === labelValue) {
@@ -154,7 +154,7 @@
 
     _computeHiddenClass(permittedLabels, label) {
       return !this._computeAnyPermittedLabelValues(permittedLabels, label) ?
-          'hidden' : '';
+        'hidden' : '';
     },
 
     _computePermittedLabelValues(permittedLabels, label) {
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 f986a58..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
@@ -123,7 +123,7 @@
       const labelName = 'Code-Review';
       assert.strictEqual(element._getVoteForAccount(
           element.change.labels, labelName, element.account),
-          '+1');
+      '+1');
     });
 
     test('_computeColumns', () => {
@@ -187,10 +187,10 @@
           {name: 'Verified', value: null}
       ]);
       element.set(['change', 'labels', 'Verified', 'all'],
-         [{_account_id: 123, value: 1}]);
+          [{_account_id: 123, value: 1}]);
       assert.deepEqual(element._labels, [
-          {name: 'Code-Review', value: null},
-          {name: 'Verified', value: '+1'},
+        {name: 'Code-Review', value: null},
+        {name: 'Verified', value: '+1'},
       ]);
     });
   });
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 ba4a1a9..d90132d 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
@@ -102,8 +102,8 @@
       el.set('message.expanded', true);
       let top = el.offsetTop;
       for (let offsetParent = el.offsetParent;
-           offsetParent;
-           offsetParent = offsetParent.offsetParent) {
+        offsetParent;
+        offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
       }
       window.scrollTo(0, top);
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 76c5980..18a136e 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
@@ -368,7 +368,7 @@
 
     _computeSubmittedTogetherClass(submittedTogether) {
       if (!submittedTogether || (
-          submittedTogether.changes.length === 0 &&
+        submittedTogether.changes.length === 0 &&
           !submittedTogether.non_visible_changes)) {
         return 'hidden';
       }
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 06b7a5d..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
@@ -321,7 +321,7 @@
         sandbox.stub(element, '_getCherryPicks')
             .returns(Promise.resolve());
         conflictsStub = sandbox.stub(element, '_getConflicts')
-          .returns(Promise.resolve());
+            .returns(Promise.resolve());
       });
 
       test('request conflicts if open and mergeable', () => {
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 cc7fbe6..b2b1588 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
@@ -87,7 +87,7 @@
      * @event comment-refresh
      */
 
-     /**
+    /**
       * Fires when the state of the send button (enabled/disabled) changes.
       *
       * @event send-disabled-changed
@@ -249,7 +249,7 @@
       this.fetchChangeUpdates(this.change, this.$.restAPI)
           .then(result => {
             this.knownLatestState = result.isLatest ?
-                LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
+              LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
           });
 
       this._focusOn(opt_focusTarget);
@@ -394,16 +394,16 @@
 
       return this.$.restAPI.removeChangeReviewer(this.change._number,
           account._account_id).then(response => {
-            if (!response.ok) { return response; }
+        if (!response.ok) { return response; }
 
-            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);
-                break;
-              }
-            }
-          });
+        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);
+            break;
+          }
+        }
+      });
     },
 
     _mapReviewer(reviewer) {
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 db98041..76f6f0b 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
@@ -414,8 +414,8 @@
       }).then(() => {
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
         const additions = cc ?
-            element.$.ccs.additions() :
-            element.$.reviewers.additions();
+          element.$.ccs.additions() :
+          element.$.reviewers.additions();
         assert.deepEqual(
             additions,
             [
@@ -843,7 +843,7 @@
       // Send and purge and verify moves, delete cc3.
       element.send()
           .then(keepReviewers =>
-              element._purgeReviewersPendingRemove(false, keepReviewers))
+            element._purgeReviewersPendingRemove(false, keepReviewers))
           .then(() => {
             assert.deepEqual(
                 mutations, [
@@ -1084,7 +1084,7 @@
           /* reviewersMutated= */ false,
           /* labelsChanged= */ false,
           /* includeComments= */ false,
-          /* disabled= */ false,
+          /* disabled= */ false
       ));
       assert.isTrue(fn(
           /* buttonLabel= */ 'Send',
@@ -1093,7 +1093,7 @@
           /* reviewersMutated= */ false,
           /* labelsChanged= */ false,
           /* includeComments= */ false,
-          /* disabled= */ false,
+          /* disabled= */ false
       ));
       // Mock nonempty comment draft array, with seding comments.
       assert.isFalse(fn(
@@ -1103,7 +1103,7 @@
           /* reviewersMutated= */ false,
           /* labelsChanged= */ false,
           /* includeComments= */ true,
-          /* disabled= */ false,
+          /* disabled= */ false
       ));
       // Mock nonempty comment draft array, without seding comments.
       assert.isTrue(fn(
@@ -1113,7 +1113,7 @@
           /* reviewersMutated= */ false,
           /* labelsChanged= */ false,
           /* includeComments= */ false,
-          /* disabled= */ false,
+          /* disabled= */ false
       ));
       // Mock nonempty change message.
       assert.isFalse(fn(
@@ -1123,7 +1123,7 @@
           /* reviewersMutated= */ false,
           /* labelsChanged= */ false,
           /* includeComments= */ false,
-          /* disabled= */ false,
+          /* disabled= */ false
       ));
       // Mock reviewers mutated.
       assert.isFalse(fn(
@@ -1133,7 +1133,7 @@
           /* reviewersMutated= */ true,
           /* labelsChanged= */ false,
           /* includeComments= */ false,
-          /* disabled= */ false,
+          /* disabled= */ false
       ));
       // Mock labels changed.
       assert.isFalse(fn(
@@ -1143,7 +1143,7 @@
           /* reviewersMutated= */ false,
           /* labelsChanged= */ true,
           /* includeComments= */ false,
-          /* disabled= */ false,
+          /* disabled= */ false
       ));
       // Whole dialog is disabled.
       assert.isTrue(fn(
@@ -1153,7 +1153,7 @@
           /* reviewersMutated= */ false,
           /* labelsChanged= */ true,
           /* includeComments= */ false,
-          /* disabled= */ true,
+          /* disabled= */ true
       ));
     });
 
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 80359e0..e3601be 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
@@ -196,7 +196,7 @@
       element.maxReviewersDisplayed = 5;
       for (let i = 0; i < 6; i++) {
         reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
       }
       element.ccsOnly = true;
 
@@ -219,7 +219,7 @@
       element.maxReviewersDisplayed = 5;
       for (let i = 0; i < 7; i++) {
         reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
       }
       element.ccsOnly = true;
 
@@ -242,7 +242,7 @@
       const reviewers = [];
       for (let i = 0; i < 7; i++) {
         reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
       }
       element.ccsOnly = true;
 
@@ -265,7 +265,7 @@
       element.maxReviewersDisplayed = 5;
       for (let i = 0; i < 100; i++) {
         reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
       }
       element.ccsOnly = true;
 
@@ -298,7 +298,7 @@
           },
           Bar: {
             all: [{_account_id: 1, permitted_voting_range: {max: 1}},
-                  {_account_id: 7, permitted_voting_range: {max: 1}}],
+              {_account_id: 7, permitted_voting_range: {max: 1}}],
           },
           FooBar: {
             all: [{_account_id: 7, value: 0}],
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 be2f0ea..518a013 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
@@ -132,8 +132,8 @@
 
       const lastNonDraftComment =
           (lastComment.__draft && thread.comments.length > 1) ?
-          thread.comments[thread.comments.length - 2] :
-          lastComment;
+            thread.comments[thread.comments.length - 2] :
+            lastComment;
 
       return {
         thread,
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 ef14370..ea0056f 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
@@ -78,7 +78,7 @@
           'none');
       assert.notEqual(getComputedStyle(element.$$('gr-account-dropdown'))
           .display,
-          'none');
+      'none');
       assert.notEqual(getComputedStyle(element.$$('.settingsButton')).display,
           'none');
     });
@@ -116,28 +116,28 @@
           /* userLinks= */[],
           adminLinks,
           /* topMenus= */[],
-          /* docBaseUrl= */ '',
+          /* docBaseUrl= */ ''
       ),
-          defaultLinks.concat({
-            title: 'Browse',
-            links: adminLinks,
-          }));
+      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,
-            }])
+      ),
+      defaultLinks.concat([
+        {
+          title: 'Your',
+          links: userLinks,
+        },
+        {
+          title: 'Browse',
+          links: adminLinks,
+        }])
       );
     });
 
@@ -185,7 +185,7 @@
           /* userLinks= */ [],
           adminLinks,
           topMenus,
-          /* baseDocUrl= */ '',
+          /* baseDocUrl= */ ''
       ), [{
         title: 'Browse',
         links: adminLinks,
@@ -221,7 +221,7 @@
           /* userLinks= */ [],
           adminLinks,
           topMenus,
-          /* baseDocUrl= */ '',
+          /* baseDocUrl= */ ''
       ), [{
         title: 'Browse',
         links: adminLinks,
@@ -260,7 +260,7 @@
           /* userLinks= */ [],
           adminLinks,
           topMenus,
-          /* baseDocUrl= */ '',
+          /* baseDocUrl= */ ''
       ), [{
         title: 'Browse',
         links: adminLinks,
@@ -297,7 +297,7 @@
           /* userLinks= */ [],
           /* adminLinks= */ [],
           topMenus,
-          /* baseDocUrl= */ '',
+          /* baseDocUrl= */ ''
       ), [{
         title: 'Faves',
         links: defaultLinks[0].links.concat([{
@@ -328,7 +328,7 @@
           userLinks,
           /* adminLinks= */ [],
           topMenus,
-          /* baseDocUrl= */ '',
+          /* baseDocUrl= */ ''
       ), [{
         title: 'Your',
         links: userLinks.concat([{
@@ -359,7 +359,7 @@
           /* userLinks= */ [],
           adminLinks,
           topMenus,
-          /* baseDocUrl= */ '',
+          /* baseDocUrl= */ ''
       ), [{
         title: 'Browse',
         links: adminLinks.concat([{
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 9eaf603..1d8f04b 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -732,11 +732,11 @@
       getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
           title = '') {
         sections = sections
-          .filter(section => (user === 'self' || !section.selfOnly))
-          .map(section => Object.assign({}, section, {
-            name: section.name,
-            query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-          }));
+            .filter(section => (user === 'self' || !section.selfOnly))
+            .map(section => Object.assign({}, section, {
+              name: section.name,
+              query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+            }));
         return {title, sections};
       },
     };
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 30d322e..6306933 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -41,14 +41,6 @@
     DETECTED: 'Extension detected',
   };
 
-  // Page visibility related constants.
-  const PAGE_VISIBILITY = {
-    TYPE: 'lifecycle',
-    CATEGORY: 'Page Visibility',
-    // Reported events - alphabetize below.
-    STARTED_HIDDEN: 'hidden',
-  };
-
   // Navigation reporting constants.
   const NAVIGATION = {
     TYPE: 'nav-report',
@@ -107,8 +99,10 @@
 
   const pending = [];
 
+  // Variables that hold context info in global scope
   const loadedPlugins = [];
   const detectedExtensions = [];
+  let reportRepoName = undefined;
 
   const onError = function(oldOnError, msg, url, line, column, error) {
     if (oldOnError) {
@@ -188,7 +182,13 @@
     reporter(...args) {
       const report = (this._isMetricsPluginLoaded() && !pending.length) ?
         this.defaultReporter : this.cachingReporter;
-      args.splice(4, 0, loadedPlugins, detectedExtensions);
+      const contextInfo = {
+        loadedPlugins,
+        detectedExtensions,
+        repoName: reportRepoName,
+        isInBackgroundTab: document.visibilityState === 'hidden',
+      };
+      args.splice(4, 0, contextInfo);
       report.apply(this, args);
     },
 
@@ -198,22 +198,27 @@
      * @param {string} category
      * @param {string} eventName
      * @param {string|number} eventValue
-     * @param {Array} plugins
-     * @param {Array} extensions
+     * @param {Object} contextInfo
      * @param {boolean|undefined} opt_noLog If true, the event will not be
      *     logged to the JS console.
      */
-    defaultReporter(type, category, eventName, eventValue,
-        loadedPlugins, detectedExtensions, opt_noLog) {
+    defaultReporter(type, category, eventName, eventValue, contextInfo,
+        opt_noLog) {
       const detail = {
         type,
         category,
         name: eventName,
         value: eventValue,
       };
-      if (category === TIMING.CATEGORY_UI_LATENCY) {
-        detail.loadedPlugins = loadedPlugins;
-        detail.detectedExtensions = detectedExtensions;
+      if (category === TIMING.CATEGORY_UI_LATENCY && contextInfo) {
+        detail.loadedPlugins = contextInfo.loadedPlugins;
+        detail.detectedExtensions = contextInfo.detectedExtensions;
+      }
+      if (contextInfo && contextInfo.repoName) {
+        detail.repoName = contextInfo.repoName;
+      }
+      if (contextInfo && contextInfo.isInBackgroundTab !== undefined) {
+        detail.inBackgroundTab = contextInfo.isInBackgroundTab;
       }
       document.dispatchEvent(new CustomEvent(type, {detail}));
       if (opt_noLog) { return; }
@@ -235,39 +240,34 @@
      * @param {string} category
      * @param {string} eventName
      * @param {string|number} eventValue
-     * @param {Array} plugins
-     * @param {Array} extensions
+     * @param {Object} contextInfo
      * @param {boolean|undefined} opt_noLog If true, the event will not be
      *     logged to the JS console.
      */
-    cachingReporter(type, category, eventName, eventValue,
-        plugins, extensions, opt_noLog) {
+    cachingReporter(type, category, eventName, eventValue, contextInfo,
+        opt_noLog) {
       if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
         console.error(eventValue && eventValue.error || eventName);
       }
       if (this._isMetricsPluginLoaded()) {
         if (pending.length) {
           for (const args of pending.splice(0)) {
-            this.reporter(...args);
+            this.defaultReporter(...args);
           }
         }
-        this.reporter(type, category, eventName, eventValue,
-            plugins, extensions, opt_noLog);
+        this.defaultReporter(type, category, eventName, eventValue, contextInfo,
+            opt_noLog);
       } else {
-        pending.push([type, category, eventName, eventValue,
-          plugins, extensions, opt_noLog]);
+        pending.push([type, category, eventName, eventValue, contextInfo,
+          opt_noLog]);
       }
     },
 
     /**
      * User-perceived app start time, should be reported when the app is ready.
      */
-    appStarted(hidden) {
+    appStarted() {
       this.timeEnd(TIMING.APP_STARTED);
-      if (hidden) {
-        this.reporter(PAGE_VISIBILITY.TYPE, PAGE_VISIBILITY.CATEGORY,
-            PAGE_VISIBILITY.STARTED_HIDDEN);
-      }
     },
 
     /**
@@ -296,6 +296,7 @@
       this.time(TIMER.DIFF_VIEW_DISPLAYED);
       this.time(TIMER.DIFF_VIEW_LOAD_FULL);
       this.time(TIMER.FILE_LIST_DISPLAYED);
+      reportRepoName = undefined;
     },
 
     locationChanged(page) {
@@ -518,6 +519,10 @@
       this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
           'ErrorDialog: ' + message, {error: new Error(message)});
     },
+
+    setRepoName(repoName) {
+      reportRepoName = repoName;
+    },
   });
 
   window.GrReporting = GrReporting;
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 c357979..163bba5 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
@@ -64,15 +64,11 @@
 
     test('appStarted', () => {
       sandbox.stub(element, 'now').returns(42);
-      element.appStarted(true);
+      element.appStarted();
       assert.isTrue(
           element.reporter.calledWithExactly(
               'timing-report', 'UI Latency', 'App Started', 42
-      ));
-      assert.isTrue(
-          element.reporter.calledWithExactly(
-              'lifecycle', 'Page Visibility', 'hidden'
-      ));
+          ));
     });
 
     test('WebComponentsReady', () => {
@@ -289,9 +285,9 @@
         // element.pluginLoaded('foo');
         element.time('timeAction');
         element.timeEnd('timeAction');
-        assert.isTrue(element.defaultReporter.getCall(1).calledWith(
+        assert.isTrue(element.defaultReporter.getCall(1).calledWithMatch(
             'timing-report', 'UI Latency', 'timeAction', 0,
-            ['metrics-xyz1']
+            {loadedPlugins: ['metrics-xyz1']}
         ));
       });
 
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 b0fc5b3..6b979e6 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -461,8 +461,8 @@
         // If there is a repo name provided, make sure to substitute it into the
         // ${repo} (or legacy ${project}) query tokens.
         const query = opt_repoName ?
-            section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
-            section.query;
+          section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
+          section.query;
         return encodeURIComponent(section.name) + '=' +
             encodeURIComponent(query);
       });
@@ -1042,11 +1042,13 @@
     },
 
     _handleProjectDashboardRoute(data) {
+      const project = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.DASHBOARD,
-        project: data.params[0],
+        project,
         dashboard: decodeURIComponent(data.params[1]),
       });
+      this.$.reporting.setRepoName(project);
     },
 
     _handleGroupInfoRoute(data) {
@@ -1121,27 +1123,33 @@
     },
 
     _handleRepoCommandsRoute(data) {
+      const repo = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.REPO,
         detail: Gerrit.Nav.RepoDetailView.COMMANDS,
-        repo: data.params[0],
+        repo,
       });
+      this.$.reporting.setRepoName(repo);
     },
 
     _handleRepoAccessRoute(data) {
+      const repo = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.REPO,
         detail: Gerrit.Nav.RepoDetailView.ACCESS,
-        repo: data.params[0],
+        repo,
       });
+      this.$.reporting.setRepoName(repo);
     },
 
     _handleRepoDashboardsRoute(data) {
+      const repo = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.REPO,
         detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-        repo: data.params[0],
+        repo,
       });
+      this.$.reporting.setRepoName(repo);
     },
 
     _handleBranchListOffsetRoute(data) {
@@ -1242,10 +1250,12 @@
     },
 
     _handleRepoRoute(data) {
+      const repo = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.REPO,
-        repo: data.params[0],
+        repo,
       });
+      this.$.reporting.setRepoName(repo);
     },
 
     _handlePluginListOffsetRoute(data) {
@@ -1307,6 +1317,7 @@
         view: Gerrit.Nav.View.CHANGE,
       };
 
+      this.$.reporting.setRepoName(params.project);
       this._redirectOrNavigate(params);
     },
 
@@ -1326,7 +1337,7 @@
         params.leftSide = address.leftSide;
         params.lineNum = address.lineNum;
       }
-
+      this.$.reporting.setRepoName(params.project);
       this._redirectOrNavigate(params);
     },
 
@@ -1368,24 +1379,28 @@
 
     _handleDiffEditRoute(ctx) {
       // Parameter order is based on the regex group number matched.
+      const project = ctx.params[0];
       this._redirectOrNavigate({
-        project: ctx.params[0],
+        project,
         changeNum: ctx.params[1],
         patchNum: ctx.params[2],
         path: ctx.params[3],
         view: Gerrit.Nav.View.EDIT,
       });
+      this.$.reporting.setRepoName(project);
     },
 
     _handleChangeEditRoute(ctx) {
       // Parameter order is based on the regex group number matched.
+      const project = ctx.params[0];
       this._redirectOrNavigate({
-        project: ctx.params[0],
+        project,
         changeNum: ctx.params[1],
         patchNum: ctx.params[3],
         view: Gerrit.Nav.View.CHANGE,
         edit: true,
       });
+      this.$.reporting.setRepoName(project);
     },
 
     /**
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 2fe940c..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
@@ -61,8 +61,8 @@
 
     getActiveElement = () => {
       return document.activeElement.shadowRoot ?
-          document.activeElement.shadowRoot.activeElement :
-          document.activeElement;
+        document.activeElement.shadowRoot.activeElement :
+        document.activeElement;
     };
 
     test('enter in search input fires event', done => {
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 94de00c..7dff30b 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
@@ -144,8 +144,8 @@
       return accounts.map(account => ({
         label: account.name || '',
         text: account.email ?
-            `${predicate}:${account.email}` :
-            `${predicate}:"${this._accountOrAnon(account)}"`,
+          `${predicate}:${account.email}` :
+          `${predicate}:"${this._accountOrAnon(account)}"`,
       }));
     },
   });
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 efed78d..1ac307f 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
@@ -248,9 +248,9 @@
     const all = comments.concat(drafts).concat(robotComments);
 
     const baseComments = all.filter(c =>
-        this._isInBaseOfPatchRange(c, patchRange));
+      this._isInBaseOfPatchRange(c, patchRange));
     const revisionComments = all.filter(c =>
-        this._isInRevisionOfPatchRange(c, patchRange));
+      this._isInRevisionOfPatchRange(c, patchRange));
 
     return {
       meta: {
@@ -348,7 +348,7 @@
     const threads = this.getCommentThreads(this._sortComments(comments));
 
     const unresolvedThreads = threads
-      .filter(thread =>
+        .filter(thread =>
           thread.comments.length &&
           thread.comments[thread.comments.length - 1].unresolved);
 
@@ -491,7 +491,7 @@
 
       return Promise.all(promises).then(([comments, robotComments, drafts]) => {
         this._changeComments = new ChangeComments(comments,
-          robotComments, drafts, changeNum);
+            robotComments, drafts, changeNum);
         return this._changeComments;
       });
     },
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 c44e8c4..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
@@ -109,7 +109,7 @@
       let draftStub;
       setup(() => {
         commentStub = sandbox.stub(element.$.restAPI, 'getDiffComments')
-          .returns(Promise.resolve({}));
+            .returns(Promise.resolve({}));
         robotCommentStub = sandbox.stub(element.$.restAPI,
             'getDiffRobotComments').returns(Promise.resolve({}));
         draftStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts')
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 0a51d92..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
@@ -105,7 +105,7 @@
     let tr = content.parentElement.parentElement;
     while (tr = tr.nextSibling) {
       if (tr.classList.contains('both') || (
-          (side === 'left' && tr.classList.contains('remove')) ||
+        (side === 'left' && tr.classList.contains('remove')) ||
           (side === 'right' && tr.classList.contains('add')))) {
         return tr.querySelector('.contentText');
       }
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 69c2419..2a57162 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
@@ -219,8 +219,8 @@
         getLineNumberByChild(node) {
           const lineEl = this.getLineElByChild(node);
           return lineEl ?
-              parseInt(lineEl.getAttribute('data-value'), 10) :
-              null;
+            parseInt(lineEl.getAttribute('data-value'), 10) :
+            null;
         },
 
         getContentByLine(lineNumber, opt_side, opt_root) {
@@ -249,7 +249,7 @@
 
         getSideByLineEl(lineEl) {
           return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
-          GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
+            GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
         },
 
         emitGroup(group, sectionEl) {
@@ -304,7 +304,7 @@
           let builder = null;
           if (this.isImageDiff) {
             builder = new GrDiffBuilderImage(diff, prefs, this.diffElement,
-              this.baseImage, this.revisionImage);
+                this.baseImage, this.revisionImage);
           } else if (diff.binary) {
             // If the diff is binary, but not an image.
             return new GrDiffBuilderBinary(diff, prefs, this.diffElement);
@@ -352,8 +352,8 @@
 
                 // If endIndex isn't present, continue to the end of the line.
                 const endIndex = highlight.endIndex === undefined ?
-                    line.text.length :
-                    highlight.endIndex;
+                  line.text.length :
+                  highlight.endIndex;
 
                 GrAnnotation.annotateElement(
                     contentEl,
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 c25e90c..5eb8b09 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
@@ -183,7 +183,7 @@
           continue;
         }
         const lineNumber = opt_side === 'left' ?
-            line.beforeNumber : line.afterNumber;
+          line.beforeNumber : line.afterNumber;
         if (lineNumber < start || lineNumber > end) { continue; }
 
         if (out_lines) { out_lines.push(line); }
@@ -292,6 +292,7 @@
       e.detail = {
         groups,
         section,
+        numLines,
       };
       // Let it bubble up the DOM tree.
     });
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 a53b86e..67c562c 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
@@ -196,7 +196,7 @@
       if (!this.diffRow) return null;
 
       const hostOwner = Polymer.dom(/** @type {Node} */ (this.diffRow))
-        .getOwnerRoot();
+          .getOwnerRoot();
       if (hostOwner && hostOwner.host &&
           hostOwner.host.tagName === 'GR-DIFF') {
         return hostOwner.host;
@@ -232,8 +232,8 @@
       if (!this.diffRow) {
         // does not scroll during init unless requested
         const scrollingBehaviorForInit = this.initialLineNumber ?
-            ScrollBehavior.KEEP_VISIBLE :
-            ScrollBehavior.NEVER;
+          ScrollBehavior.KEEP_VISIBLE :
+          ScrollBehavior.NEVER;
         this._scrollBehavior = scrollingBehaviorForInit;
         this.reInitCursor();
       }
@@ -315,7 +315,7 @@
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
           this._isTargetBlank()) {
         this.side = this.side === DiffSides.LEFT ?
-            DiffSides.RIGHT : DiffSides.LEFT;
+          DiffSides.RIGHT : DiffSides.LEFT;
       }
     },
 
@@ -391,15 +391,15 @@
         splice = changeRecord.indexSplices[spliceIdx];
 
         for (i = splice.index;
-            i < splice.index + splice.addedCount;
-            i++) {
+          i < splice.index + splice.addedCount;
+          i++) {
           this.listen(this.diffs[i], 'render-start', '_handleDiffRenderStart');
           this.listen(this.diffs[i], 'render-content', 'handleDiffUpdate');
         }
 
         for (i = 0;
-            i < splice.removed && splice.removed.length;
-            i++) {
+          i < splice.removed && splice.removed.length;
+          i++) {
           this.unlisten(splice.removed[i],
               'render-start', '_handleDiffRenderStart');
           this.unlisten(splice.removed[i],
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 86d5e45..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
@@ -168,7 +168,7 @@
       assert.equal(layer4[0].textContent +
           layer4[1].textContent +
           layer4[2].textContent,
-          layers[3]);
+      layers[3]);
     });
 
     test('splitTextNode', () => {
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 5472489..82c3a8b 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
@@ -137,7 +137,7 @@
       }
 
       return this.commentRanges.findIndex(commentRange =>
-          commentRange.side === side && rangesEqual(commentRange.range, range));
+        commentRange.side === side && rangesEqual(commentRange.range, range));
     },
 
     /**
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 7ca5f5e..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
@@ -272,12 +272,12 @@
       };
 
       const getActionRange = () =>
-          Polymer.dom(element.root).querySelector(
-              'gr-selection-action-box').range;
+        Polymer.dom(element.root).querySelector(
+            'gr-selection-action-box').range;
 
       const getActionSide = () =>
-          Polymer.dom(element.root).querySelector(
-              'gr-selection-action-box').side;
+        Polymer.dom(element.root).querySelector(
+            'gr-selection-action-box').side;
 
       const getLineElByChild = node => {
         const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
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 54398b8..6167e88 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
@@ -250,6 +250,7 @@
       'render-content': '_handleRenderContent',
 
       'normalize-range': '_handleNormalizeRange',
+      'diff-context-expanded': '_handleDiffContextExpanded',
     },
 
     observers: [
@@ -271,11 +272,11 @@
     },
 
     /**
-     * @param {boolean=} haveParamsChanged ends reporting events that started
-     * on location change.
+     * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
+     * signal to report metrics event that started on location change.
      * @return {!Promise}
      **/
-    reload(haveParamsChanged) {
+    reload(shouldReportMetric) {
       this._loading = true;
       this._errorMessage = null;
       const whitespaceLevel = this._getIgnoreWhitespace();
@@ -288,7 +289,7 @@
       }
       this._layers = layers;
 
-      if (haveParamsChanged) {
+      if (shouldReportMetric) {
         // We listen on render viewport only on DiffPage (on paramsChanged)
         this._listenToViewportRender();
       }
@@ -349,6 +350,11 @@
                   resolve();
                 }
                 this.removeEventListener('render', callback);
+                if (shouldReportMetric) {
+                  // We report diffViewContentDisplayed only on reload caused
+                  // by params changed - expected only on Diff Page.
+                  this.$.reporting.diffViewContentDisplayed();
+                }
               };
               this.addEventListener('render', callback);
               this.diff = diff;
@@ -511,7 +517,7 @@
       // digits. Diffs with no delta are considered 0%.
       const totalDelta = rebaseDelta + nonRebaseDelta;
       const percentRebaseDelta = !totalDelta ? 0 :
-          Math.round(100 * rebaseDelta / totalDelta);
+        Math.round(100 * rebaseDelta / totalDelta);
 
       // Report the due_to_rebase percentage in the "diff" category when
       // applicable.
@@ -588,7 +594,7 @@
         // thread and append to it.
         if (comment.in_reply_to) {
           const thread = threads.find(thread =>
-              thread.comments.some(c => c.id === comment.in_reply_to));
+            thread.comments.some(c => c.id === comment.in_reply_to));
           if (thread) {
             thread.comments.push(comment);
             continue;
@@ -729,7 +735,7 @@
       }
       function matchesRange(threadEl) {
         const threadRange = /** @type {!Gerrit.Range} */(
-            JSON.parse(threadEl.getAttribute('range')));
+          JSON.parse(threadEl.getAttribute('range')));
         return Gerrit.rangesEqual(threadRange, range);
       }
 
@@ -780,7 +786,7 @@
         matchers.push(matchesFileComment);
       }
       return threadEls.filter(threadEl =>
-          matchers.some(matcher => matcher(threadEl)));
+        matchers.some(matcher => matcher(threadEl)));
     },
 
     _getIgnoreWhitespace() {
@@ -832,7 +838,7 @@
      */
     _computeParentIndex(patchRangeRecord) {
       return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
-          this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
+        this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
     },
 
     _handleCommentSave(e) {
@@ -926,8 +932,8 @@
       if (!diff) return false;
       return diff.content.some(section => {
         const lines = section.ab ?
-              section.ab :
-              (section.a || []).concat(section.b || []);
+          section.ab :
+          (section.a || []).concat(section.b || []);
         return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
       });
     },
@@ -950,7 +956,6 @@
 
     _handleRenderContent() {
       this.$.reporting.timeEnd(TimingLabel.CONTENT);
-      this.$.reporting.diffViewContentDisplayed();
     },
 
     _handleNormalizeRange(event) {
@@ -958,5 +963,10 @@
           `Modified invalid comment range on l. ${event.detail.lineNum}` +
           ` of the ${event.detail.side} side`);
     },
+
+    _handleDiffContextExpanded(event) {
+      this.$.reporting.reportInteraction(
+          'diff-context-expanded', event.detail.numLines);
+    },
   });
 })();
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 16b7728..88b1e1c 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
@@ -320,7 +320,7 @@
         element.patchRange = {};
         element.$.restAPI.getDiffPreferences().then(prefs => {
           element.prefs = prefs;
-          return element.reload();
+          return element.reload(true);
         });
         // Multiple cascading microtasks are scheduled.
         setTimeout(() => {
@@ -509,7 +509,7 @@
               'getB64FileContents',
               (changeId, patchNum, path, opt_parentIndex) => {
                 return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
-                    mockFile2);
+                  mockFile2);
               });
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
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 4716544..58cc46f 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
@@ -290,7 +290,7 @@
       if (this.context !== WHOLE_FILE) {
         const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
         const hiddenEnd = lineCount - (
-            firstUncollapsibleChunkIndex === chunks.length ?
+          firstUncollapsibleChunkIndex === chunks.length ?
             0 : this.context);
         groups = GrDiffGroup.hideInContextControl(
             groups, hiddenStart, hiddenEnd);
@@ -483,7 +483,7 @@
 
         if (chunk.common && chunk.a.length != chunk.b.length) {
           throw new Error(
-            'DiffContent with common=true must always have equal length');
+              'DiffContent with common=true must always have equal length');
         }
         const numLines = this._commonChunkLength(chunk);
         const chunkEnds = this._findChunkEndsAtKeyLocations(
@@ -494,7 +494,7 @@
         if (chunk.ab) {
           result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
               .map(({lines, keyLocation}) =>
-                  Object.assign({}, chunk, {ab: 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);
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 055b200..c35c304 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
@@ -79,8 +79,8 @@
         this._setClasses([
           SelectionClass.COMMENT,
           node.commentSide === 'left' ?
-          SelectionClass.LEFT :
-          SelectionClass.RIGHT,
+            SelectionClass.LEFT :
+            SelectionClass.RIGHT,
         ]);
         return true;
       }
@@ -106,8 +106,8 @@
         const side = this.diffBuilder.getSideByLineEl(lineEl);
 
         targetClasses.push(side === 'left' ?
-            SelectionClass.LEFT :
-            SelectionClass.RIGHT);
+          SelectionClass.LEFT :
+          SelectionClass.RIGHT);
 
         if (commentSelected) {
           targetClasses.push(SelectionClass.COMMENT);
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 8158c96..a3ddaf7 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
@@ -272,8 +272,8 @@
       const patchRange = patchRangeRecord.base;
       return this.$.restAPI.getChangeFilePathsAsSpeciallySortedArray(
           changeNum, patchRange).then(files => {
-            this._fileList = files;
-          });
+        this._fileList = files;
+      });
     },
 
     _getDiffPreferences() {
@@ -566,8 +566,8 @@
       let idx = fileList.indexOf(path);
       if (idx === -1) {
         const file = direction > 0 ?
-            fileList[0] :
-            fileList[fileList.length - 1];
+          fileList[0] :
+          fileList[fileList.length - 1];
         return {path: file};
       }
 
@@ -706,8 +706,8 @@
         // is specified.
         this._getReviewedStatus(this.editMode, this._changeNum,
             this._patchRange.patchNum, this._path).then(status => {
-              this.$.reviewed.checked = status;
-            });
+          this.$.reviewed.checked = status;
+        });
         return;
       }
 
@@ -1138,7 +1138,7 @@
       // so we resolve the right "next" file.
       const unreviewedFiles = this._fileList
           .filter(file =>
-          (file === this._path || !this._reviewedFiles.has(file)));
+            (file === this._path || !this._reviewedFiles.has(file)));
       this._navToFile(this._path, unreviewedFiles, 1);
     },
 
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 6427844..573d75f 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
@@ -269,28 +269,28 @@
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
           'wheatley.md', '10', '5'),
-          'Should navigate to /c/42/5..10/wheatley.md');
+      'Should navigate to /c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
           'glados.txt', '10', '5'),
-          'Should navigate to /c/42/5..10/glados.txt');
+      'Should navigate to /c/42/5..10/glados.txt');
       element._path = 'glados.txt';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change, 'chell.go',
           '10', '5'),
-          'Should navigate to /c/42/5..10/chell.go');
+      'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
       assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
           '5'),
-          'Should navigate to /c/42/5..10');
+      'Should navigate to /c/42/5..10');
     });
 
     test('keyboard shortcuts with old patch number', () => {
@@ -332,13 +332,13 @@
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
           'wheatley.md', '1', PARENT),
-          'Should navigate to /c/42/1/wheatley.md');
+      'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
           'glados.txt', '1', PARENT),
-          'Should navigate to /c/42/1/glados.txt');
+      'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
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 02ca7e5..7c42fa2 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
@@ -185,11 +185,11 @@
 
       if (before.length) {
         beforeGroups.push(before.length === group.lines.length ?
-            group : group.cloneWithLines(before));
+          group : group.cloneWithLines(before));
       }
       if (after.length) {
         afterGroups.push(after.length === group.lines.length ?
-            group : group.cloneWithLines(after));
+          group : group.cloneWithLines(after));
       }
     }
     return [beforeGroups, afterGroups];
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 fc3862b..9c38718 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -118,6 +118,14 @@
      * @event render
      */
 
+    /**
+     * Fired for interaction reporting when a diff context is expanded.
+     * Contains an event.detail with numLines about the number of lines that
+     * were expanded.
+     *
+     * @event diff-context-expanded
+     */
+
     properties: {
       changeNum: String,
       noAutoRender: {
@@ -169,7 +177,7 @@
         observer: '_viewModeObserver',
       },
 
-       /** @type ?Gerrit.LineOfInterest */
+      /** @type ?Gerrit.LineOfInterest */
       lineOfInterest: Object,
 
       loading: {
@@ -324,8 +332,8 @@
       // up the diff, because they are in the shadow DOM of the gr-diff element.
       // This takes the shadow DOM selection if one exists.
       return this.root.getSelection ?
-          this.root.getSelection() :
-          document.getSelection();
+        this.root.getSelection() :
+        document.getSelection();
     },
 
     _observeNodes() {
@@ -471,6 +479,9 @@
       const el = Polymer.dom(e).localTarget;
 
       if (el.classList.contains('showContext')) {
+        this.fire('diff-context-expanded', {
+          numLines: e.detail.numLines,
+        });
         this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
       } else if (el.classList.contains('lineNum')) {
         this.addDraftAtLine(el);
@@ -524,8 +535,8 @@
         return false;
       }
       const patchNum = el.classList.contains(DiffSide.LEFT) ?
-          this.patchRange.basePatchNum :
-          this.patchRange.patchNum;
+        this.patchRange.basePatchNum :
+        this.patchRange.patchNum;
 
       const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
       const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
@@ -892,8 +903,8 @@
         chunkIndex--;
         chunk = diff.content[chunkIndex];
       } while (
-          // We haven't reached the beginning.
-          chunkIndex >= 0 &&
+      // We haven't reached the beginning.
+        chunkIndex >= 0 &&
 
           // The chunk doesn't have both sides.
           !chunk.ab &&
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 09342e1..ec2f5f1 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
@@ -1070,7 +1070,7 @@
             /* loading= */ false,
             element.prefs,
             element._diffLength
-          ));
+        ));
       });
 
       test('do not show the message if still loading', () => {
@@ -1079,7 +1079,7 @@
             /* loading= */ true,
             element.prefs,
             element._diffLength
-          ));
+        ));
       });
 
       test('do not show the message if contains valid changes', () => {
@@ -1098,7 +1098,7 @@
             /* loading= */ false,
             element.prefs,
             element._diffLength
-          ));
+        ));
       });
 
       test('do not show message if ignore whitespace is disabled', () => {
@@ -1116,7 +1116,7 @@
             /* loading= */ false,
             element.prefs,
             element._diffLength
-          ));
+        ));
       });
     });
 
@@ -1129,7 +1129,7 @@
       element = fixture('basic');
       element.prefs = {};
       renderStub = sandbox.stub(element.$.diffBuilder, 'render')
-            .returns(Promise.resolve());
+          .returns(Promise.resolve());
       element.addEventListener('render', event => {
         assert.isTrue(event.detail.contentRendered);
         done();
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 cdc5f30..2dae7ed 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
@@ -80,7 +80,7 @@
 
       const parentCounts = revisionInfo.getParentCountMap();
       const currentParentCount = parentCounts.hasOwnProperty(patchNum) ?
-          parentCounts[patchNum] : 1;
+        parentCounts[patchNum] : 1;
       const maxParents = revisionInfo.getMaxParents();
       const isMerge = currentParentCount > 1;
 
@@ -246,7 +246,7 @@
     _computePatchSetDescription(revisions, patchNum, opt_addFrontSpace) {
       const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
-          (opt_addFrontSpace ? ' ' : '') +
+        (opt_addFrontSpace ? ' ' : '') +
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
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 66fa974..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
@@ -185,7 +185,7 @@
       assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
           patchNum, sortedRevisions, element.changeComments,
           element.revisionInfo),
-          expectedResult);
+      expectedResult);
     });
 
     test('_computeBaseDropdownContent called when patchNum updates', () => {
@@ -344,7 +344,7 @@
 
       assert.deepEqual(element._computePatchDropdownContent(availablePatches,
           basePatchNum, sortedRevisions, element.changeComments),
-          expectedResult);
+      expectedResult);
     });
 
     test('filesWeblinks', () => {
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 c7c9b9d..2e74ff4 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
@@ -66,12 +66,12 @@
     annotate(el, lineNumberEl, line) {
       let ranges = [];
       if (line.type === GrDiffLine.Type.REMOVE || (
-          line.type === GrDiffLine.Type.BOTH &&
+        line.type === GrDiffLine.Type.BOTH &&
           el.getAttribute('data-side') !== 'right')) {
         ranges = ranges.concat(this._getRangesForLine(line, 'left'));
       }
       if (line.type === GrDiffLine.Type.ADD || (
-          line.type === GrDiffLine.Type.BOTH &&
+        line.type === GrDiffLine.Type.BOTH &&
           el.getAttribute('data-side') !== 'left')) {
         ranges = ranges.concat(this._getRangesForLine(line, 'right'));
       }
@@ -134,7 +134,7 @@
         this._updateRangesMap(
             side, range, hovering, (forLine, start, end, hovering) => {
               const index = forLine.findIndex(lineRange =>
-                  lineRange.start === start && lineRange.end === end);
+                lineRange.start === start && lineRange.end === end);
               forLine[index].hovering = hovering;
             });
       }
@@ -147,7 +147,7 @@
             this._updateRangesMap(
                 side, range, hovering, (forLine, start, end) => {
                   const index = forLine.findIndex(lineRange =>
-                      lineRange.start === start && lineRange.end === end);
+                    lineRange.start === start && lineRange.end === end);
                   forLine.splice(index, 1);
                 });
           }
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 5375ef8..6c7d582 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
@@ -192,11 +192,11 @@
       // Determine the side.
       let side;
       if (line.type === GrDiffLine.Type.REMOVE || (
-          line.type === GrDiffLine.Type.BOTH &&
+        line.type === GrDiffLine.Type.BOTH &&
           el.getAttribute('data-side') !== 'right')) {
         side = 'left';
       } else if (line.type === GrDiffLine.Type.ADD || (
-          el.getAttribute('data-side') !== 'left')) {
+        el.getAttribute('data-side') !== 'left')) {
         side = 'right';
       }
 
@@ -260,12 +260,14 @@
         lastNotify: {left: 1, right: 1},
       };
 
+      const rangesCache = new Map();
+
       this._processPromise = util.makeCancelable(this._loadHLJS()
           .then(() => {
             return new Promise(resolve => {
               const nextStep = () => {
                 this._processHandle = null;
-                this._processNextLine(state);
+                this._processNextLine(state, rangesCache);
 
                 // Move to the next line in the section.
                 state.lineIndex++;
@@ -322,12 +324,21 @@
      * Highlight.js emits and emit a list of text ranges and classes for the
      * markers.
      * @param {string} str The string of HTML.
+     * @param {Map<string, !Array<!Object>>} rangesCache A map for caching
+     * ranges for each string. A cache is read and written by this method.
+     * Since diff is mostly comparing same file on two sides, there is good rate
+     * of duplication at least for parts that are on left and right parts.
      * @return {!Array<!Object>} The list of ranges.
      */
-    _rangesFromString(str) {
+    _rangesFromString(str, rangesCache) {
+      const cached = rangesCache.get(str);
+      if (cached) return cached;
+
       const div = document.createElement('div');
       div.innerHTML = str;
-      return this._rangesFromElement(div, 0);
+      const ranges = this._rangesFromElement(div, 0);
+      rangesCache.set(str, ranges);
+      return ranges;
     },
 
     _rangesFromElement(elem, offset) {
@@ -358,7 +369,7 @@
      * lines).
      * @param {!Object} state The processing state for the layer.
      */
-    _processNextLine(state) {
+    _processNextLine(state, rangesCache) {
       let baseLine;
       let revisionLine;
 
@@ -387,7 +398,8 @@
         baseLine = this._workaround(this._baseLanguage, baseLine);
         result = this._hljs.highlight(this._baseLanguage, baseLine, true,
             state.baseContext);
-        this.push('_baseRanges', this._rangesFromString(result.value));
+        this.push('_baseRanges',
+            this._rangesFromString(result.value, rangesCache));
         state.baseContext = result.top;
       }
 
@@ -396,7 +408,8 @@
         revisionLine = this._workaround(this._revisionLanguage, revisionLine);
         result = this._hljs.highlight(this._revisionLanguage, revisionLine,
             true, state.revisionContext);
-        this.push('_revisionRanges', this._rangesFromString(result.value));
+        this.push('_revisionRanges',
+            this._rangesFromString(result.value, rangesCache));
         state.revisionContext = result.top;
       }
     },
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 472db21..4e3492f 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
@@ -388,10 +388,20 @@
         '<span class="non-whtelisted-class">',
         '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
         '</span>'].join('');
-      const result = element._rangesFromString(str);
+      const result = element._rangesFromString(str, new Map());
       assert.notEqual(result.length, 0);
     });
 
+    test('_rangesFromString cache same syntax markers', () => {
+      sandbox.spy(element, '_rangesFromElement');
+      const str =
+        '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>';
+      const cacheMap = new Map();
+      element._rangesFromString(str, cacheMap);
+      element._rangesFromString(str, cacheMap);
+      assert.isTrue(element._rangesFromElement.calledOnce);
+    });
+
     test('_isSectionDone', () => {
       let state = {sectionIndex: 0, lineIndex: 0};
       assert.isFalse(element._isSectionDone(state));
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 5aed3e4..32aad5a 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
@@ -214,17 +214,17 @@
       const dialog = this._getDialogFromEvent(e);
       return this.$.restAPI.renameFileInChangeEdit(this.change._number,
           this._path, this._newPath).then(res => {
-            if (!res.ok) { return; }
-            this._closeDialog(dialog, true);
-            Gerrit.Nav.navigateToChange(this.change);
-          });
+        if (!res.ok) { return; }
+        this._closeDialog(dialog, true);
+        Gerrit.Nav.navigateToChange(this.change);
+      });
     },
 
     _queryFiles(input) {
       return this.$.restAPI.queryChangeFiles(this.change._number,
           this.patchNum, input).then(res => res.map(file => {
-            return {name: file};
-          }));
+        return {name: file};
+      }));
     },
 
     _computeIsInvisible(id, hiddenActions) {
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 df029ed..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
@@ -192,8 +192,8 @@
     let renameStub;
     let renameAutocomplete;
     const inputSelector = Polymer.Element ?
-        '.newPathIronInput' :
-        '.newPathInput';
+      '.newPathIronInput' :
+      '.newPathInput';
 
     setup(() => {
       navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
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 95cfaf6..bbcb90c 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
@@ -141,11 +141,11 @@
       }
       return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
           this._path, path).then(res => {
-            if (!res.ok) { return; }
+        if (!res.ok) { return; }
 
-            this._successfulSave = true;
-            this._viewEditInChangeView();
-          });
+        this._successfulSave = true;
+        this._viewEditInChangeView();
+      });
     },
 
     _viewEditInChangeView() {
@@ -191,13 +191,13 @@
       this.$.storage.eraseEditableContentItem(this.storageKey);
       return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
           this._newContent).then(res => {
-            this._saving = false;
-            this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
-            if (!res.ok) { return; }
+        this._saving = false;
+        this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
+        if (!res.ok) { return; }
 
-            this._content = this._newContent;
-            this._successfulSave = true;
-          });
+        this._content = this._newContent;
+        this._successfulSave = true;
+      });
     },
 
     _showAlert(message) {
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 abb8131..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
@@ -106,7 +106,7 @@
     // Calling with the same path should not navigate.
     return element._handlePathChanged({detail: mockParams.path}).then(() => {
       assert.isFalse(savePathStub.called);
-        // !ok response
+      // !ok response
       element._handlePathChanged({detail: 'newPath'}).then(() => {
         assert.isTrue(savePathStub.called);
         assert.isFalse(navigateStub.called);
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index e61bbfe..b8f789b 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -118,7 +118,7 @@
     },
 
     ready() {
-      this.$.reporting.appStarted(document.visibilityState === 'hidden');
+      this.$.reporting.appStarted();
       this.$.router.start();
 
       this.$.restAPI.getAccount().then(account => {
@@ -426,7 +426,8 @@
     },
 
     _computePluginScreenName({plugin, screen}) {
-      return Gerrit._getPluginScreenName(plugin, screen);
+      if (!plugin || !screen) return '';
+      return `${plugin}-screen-${screen}`;
     },
 
     _logWelcome() {
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 9cdcf76..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
@@ -39,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-change-metadata-api/gr-change-metadata-api.js b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
index b550f73..0454767 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
@@ -31,7 +31,7 @@
       this._createHook();
     }
     this._hook.onAttached(element =>
-        this.plugin.attributeHelper(element).bind('labels', callback));
+      this.plugin.attributeHelper(element).bind('labels', callback));
     return this;
   };
 
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 448e090..ba49291 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
@@ -95,15 +95,15 @@
         return helper.get('value').then(
             value => helper.bind('value',
                 value => plugin.attributeHelper(el).set(paramName, value))
-            );
+        );
       });
       let timeoutId;
       const timeout = new Promise(
-        resolve => timeoutId = setTimeout(() => {
-          console.warn(
-              'Timeout waiting for endpoint properties initialization: ' +
+          resolve => timeoutId = setTimeout(() => {
+            console.warn(
+                'Timeout waiting for endpoint properties initialization: ' +
               `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
-        }, INIT_PROPERTIES_TIMEOUT_MS));
+          }, INIT_PROPERTIES_TIMEOUT_MS));
       return Promise.race([timeout, Promise.all(expectProperties)])
           .then(() => {
             clearTimeout(timeoutId);
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 994d666..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
@@ -67,7 +67,7 @@
       replacementHook = plugin.registerCustomComponent(
           'second', 'other-module', {replace: true});
       // Mimic all plugins loaded.
-      Gerrit._setPluginsPending([]);
+      Gerrit._loadPlugins([]);
       flush(done);
     });
 
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 bdc7652..21da106 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
@@ -27,33 +27,29 @@
       },
     },
 
-    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);
     },
 
     /**
@@ -66,53 +62,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 || Polymer.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 e577182..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
@@ -38,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-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
index 556cfd8..e3f8694 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
@@ -46,17 +46,17 @@
     if (!this._openingPromise) {
       this._openingPromise =
           this.plugin.hook('plugin-overlay').getLastAttached()
-      .then(hookEl => {
-        const popup = document.createElement('gr-plugin-popup');
-        if (this._moduleName) {
-          const el = Polymer.dom(popup).appendChild(
-              document.createElement(this._moduleName));
-          el.plugin = this.plugin;
-        }
-        this._popup = Polymer.dom(hookEl).appendChild(popup);
-        Polymer.dom.flush();
-        return this._popup.open().then(() => this);
-      });
+              .then(hookEl => {
+                const popup = document.createElement('gr-plugin-popup');
+                if (this._moduleName) {
+                  const el = Polymer.dom(popup).appendChild(
+                      document.createElement(this._moduleName));
+                  el.plugin = this.plugin;
+                }
+                this._popup = Polymer.dom(hookEl).appendChild(popup);
+                Polymer.dom.flush();
+                return this._popup.open().then(() => this);
+              });
     }
     return this._openingPromise;
   };
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 7c7564b..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
@@ -46,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_test.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
index d34ca94..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
@@ -48,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.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
index 1de8283..7aca389 100644
--- 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
@@ -38,7 +38,7 @@
    */
   GrStyleObject.prototype.getClassName = function(element) {
     let rootNode = Polymer.Settings.useShadow
-        ? element.getRootNode() : document.body;
+      ? element.getRootNode() : document.body;
     if (rootNode === document) {
       rootNode = document.head;
     }
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
index d67a309..46bda6d 100644
--- 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
@@ -46,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([]);
       stylesApi = plugin.styles();
     });
 
@@ -76,7 +76,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([]);
       stylesApi = plugin.styles();
       displayInlineStyle = stylesApi.css('display: inline');
       displayNoneStyle = stylesApi.css('display: none');
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 82eb0f8..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
@@ -67,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.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index b0c7661..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
@@ -127,20 +127,20 @@
 
     _maybeSetName() {
       return this._hasNameChange && this.nameMutable ?
-          this.$.restAPI.setAccountName(this._account.name) :
-          Promise.resolve();
+        this.$.restAPI.setAccountName(this._account.name) :
+        Promise.resolve();
     },
 
     _maybeSetUsername() {
       return this._hasUsernameChange && this.usernameMutable ?
-          this.$.restAPI.setAccountUsername(this._username) :
-          Promise.resolve();
+        this.$.restAPI.setAccountUsername(this._username) :
+        Promise.resolve();
     },
 
     _maybeSetStatus() {
       return this._hasStatusChange ?
-          this.$.restAPI.setAccountStatus(this._account.status) :
-          Promise.resolve();
+        this.$.restAPI.setAccountStatus(this._account.status) :
+        Promise.resolve();
     },
 
     _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged) {
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 de222a9..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
@@ -152,7 +152,7 @@
         usernameChangedSpy = sandbox.spy(element, '_usernameChanged');
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
+            {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
 
         nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
             name => Promise.resolve());
@@ -280,7 +280,7 @@
       setup(() => {
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
-          {auth: {editable_account_fields: []}});
+            {auth: {editable_account_fields: []}});
 
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
             status => Promise.resolve());
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 3eb1a22..41a9800 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
@@ -123,7 +123,7 @@
 
     _hideAggreements(item, groups, signedAgreements) {
       return this._disableAggreements(item, groups, signedAgreements) ?
-          '' : 'hide';
+        '' : 'hide';
     },
 
     _disableAgreementsText(text) {
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 78025d1..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
@@ -46,11 +46,11 @@
           return;
         }
         this._keys = Object.keys(keys)
-         .map(key => {
-           const gpgKey = keys[key];
-           gpgKey.id = key;
-           return gpgKey;
-         });
+            .map(key => {
+              const gpgKey = keys[key];
+              gpgKey.id = key;
+              return gpgKey;
+            });
       });
     },
 
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
index 92836a8..b2e0973 100644
--- 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
@@ -94,7 +94,7 @@
     _inputTextChanged(text) {
       if (text.length && this.allowAnyInput) {
         this.dispatchEvent(new CustomEvent(
-                'account-text-changed', {bubbles: true, composed: true}));
+            '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
index 59792a7..6896af9 100644
--- 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
@@ -92,7 +92,7 @@
 
     test('account-text-changed not fired when input text changed without ' +
         'allowAnyInput', () => {
-          // Spy on query, as that is called when _updateSuggestions proceeds.
+      // 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);
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 740dfcf..f0620d3 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
@@ -64,19 +64,21 @@
     });
 
     test('computed fields', () => {
-      assert.equal(element._computeAccountTitle(
-          {
+      assert.equal(
+          element._computeAccountTitle({
             name: 'Andrew Bonventre',
             email: 'andybons+gerrit@gmail.com',
           }, /* additionalText= */ ''),
           'Andrew Bonventre <andybons+gerrit@gmail.com>');
 
-      assert.equal(element._computeAccountTitle(
-          {name: 'Andrew Bonventre'}, /* additionalText= */ ''),
+      assert.equal(
+          element._computeAccountTitle({
+            name: 'Andrew Bonventre',
+          }, /* additionalText= */ ''),
           'Andrew Bonventre');
 
-      assert.equal(element._computeAccountTitle(
-          {
+      assert.equal(
+          element._computeAccountTitle({
             email: 'andybons+gerrit@gmail.com',
           }, /* additionalText= */ ''),
           'Anonymous <andybons+gerrit@gmail.com>');
@@ -94,9 +96,9 @@
 
       assert.equal(element._computeShowEmailClass(
           {name: 'Andrew Bonventre'},
-          /* additionalText= */ '',
+          /* additionalText= */ ''
       ),
-          '');
+      '');
 
       assert.equal(element._computeShowEmailClass(undefined), '');
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
index 5f1d41a..9897105 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
@@ -120,7 +120,7 @@
           suggestions = suggestions.filter(this.filter);
         }
         return suggestions.map(suggestion =>
-            provider.makeSuggestionItem(suggestion));
+          provider.makeSuggestionItem(suggestion));
       });
     },
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
index 6f265e7..f931a69 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
@@ -44,6 +44,7 @@
       return item;
     }
   }
+
   suite('gr-account-list tests', () => {
     let _nextAccountId = 0;
     const makeAccount = function() {
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 1c2afaa..9c2aba9 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
@@ -49,28 +49,28 @@
     });
 
     test('methods', () => {
-      assert.equal(element._buildAvatarURL(
-          {
+      assert.equal(
+          element._buildAvatarURL({
             _account_id: 123,
           }),
           '/accounts/123/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
+      assert.equal(
+          element._buildAvatarURL({
             email: 'test@example.com',
           }),
           '/accounts/test%40example.com/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
+      assert.equal(
+          element._buildAvatarURL({
             name: 'John Doe',
           }),
           '/accounts/John%20Doe/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
+      assert.equal(
+          element._buildAvatarURL({
             username: 'John_Doe',
           }),
           '/accounts/John_Doe/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
+      assert.equal(
+          element._buildAvatarURL({
             _account_id: 123,
             avatars: [
               {
@@ -88,8 +88,8 @@
             ],
           }),
           'https://cdn.example.com/s16-p/photo.jpg');
-      assert.equal(element._buildAvatarURL(
-          {
+      assert.equal(
+          element._buildAvatarURL({
             _account_id: 123,
             avatars: [
               {
@@ -117,7 +117,7 @@
       assert.strictEqual(element.style.backgroundImage, '');
 
       // Emulate plugins loaded.
-      Gerrit._setPluginsPending([]);
+      Gerrit._loadPlugins([]);
 
       Promise.all([
         element.$.restAPI.getConfig(),
@@ -155,7 +155,7 @@
       assert.isFalse(element.hasAttribute('hidden'));
 
       // Emulate plugins loaded.
-      Gerrit._setPluginsPending([]);
+      Gerrit._loadPlugins([]);
 
       return Promise.all([
         element.$.restAPI.getConfig(),
@@ -197,7 +197,7 @@
           _account_id: 123,
         };
         // Emulate plugins loaded.
-        Gerrit._setPluginsPending([]);
+        Gerrit._loadPlugins([]);
 
         return Promise.all([
           element.$.restAPI.getConfig(),
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 b7fe8b0..ff5576cb 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
@@ -35,7 +35,7 @@
      * @event thread-changed
      */
 
-     /**
+    /**
       * gr-comment-thread exposes the following attributes that allow a
       * diff widget like gr-diff to show the thread in the right location:
       *
@@ -165,7 +165,7 @@
         commentEl.collapsed = false;
       } else {
         const range = opt_range ? opt_range :
-            lastComment ? lastComment.range : undefined;
+          lastComment ? lastComment.range : undefined;
         const unresolved = lastComment ? lastComment.unresolved : undefined;
         this.addDraft(opt_lineNum, range, unresolved);
       }
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 b6222b4..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
@@ -469,7 +469,7 @@
             done();
           });
           draftEl.fire('comment-discard', {comment: draftEl.comment},
-          {bubbles: false});
+              {bubbles: false});
         });
 
     test('first editing comment does not add __otherEditing attribute', () => {
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 d41ef7f..6881929 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -445,7 +445,7 @@
         return;
       }
       const timingLabel = this.comment.id ?
-          REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
+        REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
       const timer = this.$.reporting.getTimer(timingLabel);
       this.set('comment.__editing', false);
       return this.save().then(() => { timer.end(); });
@@ -591,13 +591,13 @@
       this._showStartRequest();
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
           draft).then(result => {
-            if (result.ok) {
-              this._showEndRequest();
-            } else {
-              this._handleFailedDraftRequest();
-            }
-            return result;
-          });
+        if (result.ok) {
+          this._showEndRequest();
+        } else {
+          this._handleFailedDraftRequest();
+        }
+        return result;
+      });
     },
 
     _getPatchNum() {
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 be40ce6..4e9ff76 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
@@ -271,8 +271,8 @@
     _getTop(target) {
       let top = target.offsetTop;
       for (let offsetParent = target.offsetParent;
-           offsetParent;
-           offsetParent = offsetParent.offsetParent) {
+        offsetParent;
+        offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
       }
       return top;
@@ -301,7 +301,7 @@
       const dims = this._getWindowDims();
       const top = this._getTop(this.target);
       const bottomIsVisible = this._targetHeight ?
-          this._targetIsVisible(top + this._targetHeight) : true;
+        this._targetIsVisible(top + this._targetHeight) : true;
       const scrollToValue = this._calculateScrollToValue(top, this.target);
 
       if (this._targetIsVisible(top)) {
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 0360145..5b1ef7f 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
@@ -167,8 +167,8 @@
 
     _timeToSecondsFormat(timeFormat) {
       return timeFormat === TimeFormats.TIME_12 ?
-          TimeFormats.TIME_12_WITH_SEC :
-          TimeFormats.TIME_24_WITH_SEC;
+        TimeFormats.TIME_12_WITH_SEC :
+        TimeFormats.TIME_24_WITH_SEC;
     },
 
     _computeFullDateStr(dateStr, timeFormat) {
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 a0bf207..99af4e6 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
@@ -89,7 +89,7 @@
     suite('24 hours time format preference', () => {
       setup(() => {
         return stubRestAPI(
-          {time_format: 'HHMM_24', relative_date_in_change_table: false}
+            {time_format: 'HHMM_24', relative_date_in_change_table: false}
         ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
@@ -139,7 +139,7 @@
       setup(() => {
         // relative_date_in_change_table is not set when false.
         return stubRestAPI(
-          {time_format: 'HHMM_12'}
+            {time_format: 'HHMM_12'}
         ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
@@ -159,7 +159,7 @@
     suite('relative date preference', () => {
       setup(() => {
         return stubRestAPI(
-          {time_format: 'HHMM_12', relative_date_in_change_table: true}
+            {time_format: 'HHMM_12', relative_date_in_change_table: true}
         ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
@@ -187,7 +187,7 @@
     suite('logged in', () => {
       setup(() => {
         return stubRestAPI(
-          {time_format: 'HHMM_12', relative_date_in_change_table: true}
+            {time_format: 'HHMM_12', relative_date_in_change_table: true}
         ).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
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 f7b10e0..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
@@ -110,7 +110,7 @@
           .returns(Promise.resolve());
       const showTrailingWhitespaceCheckbox =
           valueOf('Show trailing whitespace', 'diffPreferences')
-          .firstElementChild;
+              .firstElementChild;
       showTrailingWhitespaceCheckbox.checked = false;
       element._handleShowTrailingWhitespaceTap();
 
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 89a0725..1f0d01c 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
@@ -118,7 +118,7 @@
       });
       if (!selectedObj) { return; }
       this.text = selectedObj.triggerText? selectedObj.triggerText :
-          selectedObj.text;
+        selectedObj.text;
       this.dispatchEvent(new CustomEvent('value-change', {
         detail: {value},
         bubbles: false,
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 50a20d1..7273268 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -206,7 +206,7 @@
      */
     _computeURLHelper(host, path) {
       const base = path.startsWith(this.getBaseUrl()) ?
-          '' : this.getBaseUrl();
+        '' : this.getBaseUrl();
       return '//' + host + base + path;
     },
 
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 1e339dc..75c0201 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
@@ -112,8 +112,8 @@
 
       // TODO(wyatta) switch linkify sequence, see issue 5526.
       this._newContent = this.removeZeroWidthSpace ?
-          content.replace(/^R=\u200B/gm, 'R=') :
-          content;
+        content.replace(/^R=\u200B/gm, 'R=') :
+        content;
     },
 
     _computeSaveDisabled(disabled, content, newContent) {
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 1b31e89..3a43191 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
@@ -136,8 +136,8 @@
         target = Polymer.dom(ownerRoot).querySelector('#' + this.for);
       } else {
         target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
-            ownerRoot.host :
-            parentNode;
+          ownerRoot.host :
+          parentNode;
       }
       return target;
     },
@@ -244,7 +244,7 @@
           hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
           hovercardTop = targetTop - thisRect.height - this.offset;
           cssText += `padding-bottom:${this.offset
-              }px; margin-bottom:-${this.offset}px;`;
+          }px; margin-bottom:-${this.offset}px;`;
           break;
         case 'bottom':
           hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
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 7bd6f48..743923b 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -76,6 +76,7 @@
       <g id="restore"><path d="M12,8 L12,13 L16.28,15.54 L17,14.33 L13.5,12.25 L13.5,8 L12,8 Z M13,3 C8.03,3 4,7.03 4,12 L1,12 L4.89,15.89 L4.96,16.03 L9,12 L6,12 C6,8.13 9.13,5 13,5 C16.87,5 20,8.13 20,12 C20,15.87 16.87,19 13,19 C11.07,19 9.32,18.21 8.06,16.94 L6.64,18.36 C8.27,19.99 10.51,21 13,21 C17.97,21 22,16.97 22,12 C22,7.03 17.97,3 13,3 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="revert"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
+      <g id="revert_submission"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="stopEdit"><path d="M4 4 20 4 20 20 4 20z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
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 98268c5..b5ff46d 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
@@ -156,7 +156,7 @@
   GrAnnotationActionsInterface.prototype.getLayer = function(
       path, changeNum, patchNum) {
     const annotationLayer = new AnnotationLayer(path, changeNum, patchNum,
-                                                this._addLayerFunc);
+        this._addLayerFunc);
     this._annotationLayers.push(annotationLayer);
     return annotationLayer;
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
index 2c5f39c..4123f70 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
@@ -19,6 +19,7 @@
   'use strict';
 
   const PRELOADED_PROTOCOL = 'preloaded:';
+  const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
   let _restAPI;
   function getRestAPI() {
@@ -28,6 +29,10 @@
     return _restAPI;
   }
 
+  function getBaseUrl() {
+    return Gerrit.BaseUrlBehavior.getBaseUrl();
+  }
+
   /**
    * Retrieves the name of the plugin base on the url.
    * @param {string|URL} url
@@ -96,6 +101,9 @@
     getPluginNameFromUrl,
     send,
     getRestAPI,
+    getBaseUrl,
+    PRELOADED_PROTOCOL,
+    PLUGIN_LOADING_TIMEOUT_MS,
 
     // TEST only methods
     testOnly_resetInternalState,
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 0131912..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
@@ -58,13 +58,14 @@
         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', ()=> {
@@ -85,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-gerrit.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
index a567700..546b9f3 100644
--- 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
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
- /**
+/**
   * This defines the Gerrit instance. All methods directly attached to Gerrit
   * should be defined or linked here.
   */
@@ -23,42 +23,12 @@
 (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 UNKNOWN_PLUGIN = 'unknown';
-  const PRELOADED_PROTOCOL = 'preloaded:';
-
-  const PLUGIN_LOADING_TIMEOUT_MS = 10000;
-
-  let _reporting;
-  const getReporting = () => {
-    if (!_reporting) {
-      _reporting = document.createElement('gr-reporting');
-    }
-    return _reporting;
-  };
-
   // Import utils methods
   const {
-      getPluginNameFromUrl,
-      send,
-      getRestAPI,
+    send,
+    getRestAPI,
   } = window._apiUtils;
 
-  const API_VERSION = '0.1';
-
   /**
    * Trigger the preinstalls for bundled plugins.
    * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
@@ -72,9 +42,7 @@
 
   window.Gerrit = window.Gerrit || {};
   const Gerrit = window.Gerrit;
-
-  let _resolveAllPluginsLoaded = null;
-  let _allPluginsPromise = null;
+  Gerrit._pluginLoader = new PluginLoader();
 
   Gerrit._endpoints = new GrPluginEndpoints();
 
@@ -85,20 +53,13 @@
     const {
       testOnly_resetInternalState,
     } = window._apiUtils;
-    Gerrit._testOnly_installPreloadedPlugins = installPreloadedPlugins;
+    Gerrit._testOnly_installPreloadedPlugins = (...args) => Gerrit._pluginLoader
+        .installPreloadedPlugins(...args);
     Gerrit._testOnly_flushPreinstalls = flushPreinstalls;
     Gerrit._testOnly_resetPlugins = () => {
-      _allPluginsPromise = null;
-      _pluginsInstalled = [];
-      _pluginsPending = {};
-      _pluginsPendingCount = -1;
-      _reporting = null;
-      _resolveAllPluginsLoaded = null;
       testOnly_resetInternalState();
       Gerrit._endpoints = new GrPluginEndpoints();
-      for (const k of Object.keys(_plugins)) {
-        delete _plugins[k];
-      }
+      Gerrit._pluginLoader = new PluginLoader();
     };
   }
 
@@ -122,36 +83,7 @@
   };
 
   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);
-
-    let src = opt_src || (script && script.src);
-    if (!src || src.startsWith('data:')) {
-      src = script && 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._pluginLoader.install(callback, opt_version, opt_src);
   };
 
   Gerrit.getLoggedIn = function() {
@@ -195,96 +127,33 @@
   };
 
   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;
+    return Gerrit._pluginLoader.awaitPluginsLoaded();
   };
 
-  Gerrit._pluginLoadingTimeout = function() {
-    console.error(`Failed to load plugins: ${Object.keys(_pluginsPending)}`);
-    Gerrit._setPluginsPending([]);
-  };
+  // TODO(taoalpha): consider removing these proxy methods
+  // and using _pluginLoader directly
 
-  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);
-      getReporting().pluginLoaded(name);
-      console.log(`Plugin ${name} installed.`);
-    }
+  Gerrit._loadPlugins = function(plugins, opt_option) {
+    Gerrit._pluginLoader.loadPlugins(plugins, opt_option);
   };
 
   Gerrit._arePluginsLoaded = function() {
-    return _pluginsPendingCount === 0;
-  };
-
-  Gerrit._getPluginScreenName = function(pluginName, screenName) {
-    return `${pluginName}-screen-${screenName}`;
+    return Gerrit._pluginLoader.arePluginsLoaded;
   };
 
   Gerrit._isPluginPreloaded = function(url) {
-    const name = getPluginNameFromUrl(url);
-    if (name && Gerrit._preloadedPlugins) {
-      return name in Gerrit._preloadedPlugins;
-    } else {
-      return false;
-    }
+    return Gerrit._pluginLoader.isPluginPreloaded(url);
   };
 
-  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);
-    }
-  }
+  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.
-  installPreloadedPlugins();
+  Gerrit._pluginLoader.installPreloadedPlugins();
 })(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
index 9a05454..e81b8aa 100644
--- 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
@@ -37,11 +37,11 @@
 <script>
   suite('gr-gerrit tests', () => {
     let element;
-    let plugin;
     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', {
@@ -53,136 +53,48 @@
         },
       });
       element = fixture('basic');
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._setPluginsPending([]);
     });
 
     teardown(() => {
+      this.clock.restore();
       sandbox.restore();
       element._removeEventCallbacks();
-      plugin = null;
+      Gerrit._testOnly_resetPlugins();
     });
 
-    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._testOnly_flushPreinstalls();
+    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'));
       });
-      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('_setPluginsCount', done => {
-      stub('gr-reporting', {
-        pluginsLoaded() {
-          done();
-        },
+      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'));
       });
-      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),
+      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'));
       });
-      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('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._testOnly_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-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index 7fa2250..dc81545 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
@@ -31,6 +31,13 @@
 <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-annotation-actions-context.js"></script>
   <script src="gr-annotation-actions-js-api.js"></script>
@@ -41,5 +48,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 70a7a01..3206b21 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
@@ -25,6 +25,7 @@
     COMMIT_MSG_EDIT: 'commitmsgedit',
     COMMENT: 'comment',
     REVERT: 'revert',
+    REVERT_SUBMISSION: 'revert_submission',
     POST_REVERT: 'postrevert',
     ANNOTATE_DIFF: 'annotatediff',
     ADMIN_MENU_LINKS: 'admin-menu-links',
@@ -213,10 +214,21 @@
       return revertMsg;
     },
 
+    modifyRevertSubmissionMsg(change, revertSubmissionMsg, origMsg) {
+      for (const cb of this._getEventCallbacks(EventType.REVERT_SUBMISSION)) {
+        try {
+          revertSubmissionMsg = cb(change, revertSubmissionMsg, origMsg);
+        } catch (err) {
+          console.error(err);
+        }
+      }
+      return revertSubmissionMsg;
+    },
+
     getDiffLayers(path, changeNum, patchNum) {
       const layers = [];
       for (const annotationApi of
-           this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+        this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
         try {
           const layer = annotationApi.getLayer(path, changeNum, patchNum);
           layers.push(layer);
@@ -243,7 +255,7 @@
     getCoverageRanges(changeNum, path, basePatchNum, patchNum) {
       return Gerrit.awaitPluginsLoaded().then(() => {
         for (const annotationApi of
-            this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+          this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
           const provider = annotationApi.getCoverageProvider();
           // Only one coverage provider makes sense. If there are more, then we
           // simply ignore them.
@@ -258,7 +270,7 @@
     getAdminMenuLinks() {
       const links = [];
       for (const adminApi of
-          this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
+        this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
         links.push(...adminApi.getMenuLinks());
       }
       return links;
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 330310f..ae12940 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
@@ -35,6 +35,7 @@
 </test-fixture>
 
 <script>
+  const {PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
   suite('gr-js-api-interface tests', () => {
     let element;
     let plugin;
@@ -48,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}));
@@ -64,10 +66,11 @@
       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;
@@ -194,12 +197,15 @@
         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();
@@ -334,7 +340,6 @@
       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 229c020..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
@@ -42,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);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
index 8832a3f..4b7778c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -33,7 +33,7 @@
   GrPluginEndpoints.prototype._getOrCreateModuleInfo = function(plugin,
       endpoint, type, moduleName, domHook) {
     const existingModule = this._endpoints[endpoint].find(info =>
-        info.plugin === plugin &&
+      info.plugin === plugin &&
         info.moduleName === moduleName &&
         info.domHook === domHook
     );
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..4be38b6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
@@ -0,0 +1,393 @@
+/**
+ * @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.setAttribute('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_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
index 05f84c0..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
@@ -50,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 d44acea..6c306d9 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
@@ -26,8 +26,8 @@
 
   // Import utils methods
   const {
-      getPluginNameFromUrl,
-      send,
+    getPluginNameFromUrl,
+    send,
   } = window._apiUtils;
 
   /**
@@ -113,7 +113,7 @@
   Plugin.prototype._registerCustomComponent = function(
       endpointName, opt_moduleName, opt_options, dynamicEndpoint) {
     const type = opt_options && opt_options.replace ?
-          EndpointType.REPLACE : EndpointType.DECORATE;
+      EndpointType.REPLACE : EndpointType.DECORATE;
     const hook = this._domHooks.getDomHook(endpointName, opt_moduleName);
     const moduleName = opt_moduleName || hook.getModuleName();
     Gerrit._endpoints.registerModule(
@@ -184,14 +184,14 @@
 
   Plugin.prototype.changeActions = function() {
     return new GrChangeActionsInterface(this,
-      Plugin._sharedAPIElement.getElement(
-          Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
+        Plugin._sharedAPIElement.getElement(
+            Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
   };
 
   Plugin.prototype.changeReply = function() {
     return new GrChangeReplyInterface(this,
-      Plugin._sharedAPIElement.getElement(
-          Plugin._sharedAPIElement.Element.REPLY_DIALOG));
+        Plugin._sharedAPIElement.getElement(
+            Plugin._sharedAPIElement.Element.REPLY_DIALOG));
   };
 
   Plugin.prototype.changeView = function() {
@@ -267,10 +267,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: ()=> {},
 
@@ -321,7 +325,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({
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 3eb44e6..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
@@ -125,14 +125,14 @@
       const accountID = parseInt(target.getAttribute('data-account-id'), 10);
       this._xhrPromise =
           this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
-          .then(response => {
-            target.disabled = false;
-            if (!response.ok) { return; }
-            Gerrit.Nav.navigateToChange(this.change);
-          }).catch(err => {
-            target.disabled = false;
-            return;
-          });
+              .then(response => {
+                target.disabled = false;
+                if (!response.ok) { return; }
+                Gerrit.Nav.navigateToChange(this.change);
+              }).catch(err => {
+                target.disabled = false;
+                return;
+              });
     },
 
     _computeValueTooltip(labelInfo, score) {
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 9ccff600..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
@@ -36,8 +36,8 @@
       if (this._headerHeight === undefined) {
         let top = this._getOffsetTop(this);
         for (let offsetParent = this.offsetParent;
-           offsetParent;
-           offsetParent = this._getOffsetParent(offsetParent)) {
+          offsetParent;
+          offsetParent = this._getOffsetParent(offsetParent)) {
           top += this._getOffsetTop(offsetParent);
         }
         this._headerHeight = top;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
index 43e3922..0084932 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
@@ -76,7 +76,8 @@
       }, this._defaultOptions, opt_options);
       if (this._type === Gerrit.Auth.TYPE.ACCESS_TOKEN) {
         return this._getAccessToken().then(
-            accessToken => this._fetchWithAccessToken(url, options, accessToken)
+            accessToken =>
+              this._fetchWithAccessToken(url, options, accessToken)
         );
       } else {
         return this._fetchWithXsrfToken(url, options);
@@ -146,7 +147,7 @@
         params.push(`access_token=${accessToken}`);
         const baseUrl = Gerrit.BaseUrlBehavior.getBaseUrl();
         const pathname = baseUrl ?
-              url.substring(url.indexOf(baseUrl) + baseUrl.length) : url;
+          url.substring(url.indexOf(baseUrl) + baseUrl.length) : url;
         if (!pathname.startsWith('/a/')) {
           url = url.replace(pathname, '/a' + pathname);
         }
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 cfdc6ee..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
@@ -111,8 +111,8 @@
       test('getToken calls are cached', () => {
         return Promise.all([
           auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
-            assert.equal(getToken.callCount, 1);
-          });
+          assert.equal(getToken.callCount, 1);
+        });
       });
 
       test('getToken refreshes token', () => {
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 6233a33..b72f47c 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
@@ -887,7 +887,7 @@
         return Promise.resolve({
           changes_per_page: 25,
           default_diff_view: this._isNarrowScreen() ?
-              DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
+            DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
           diff_view: 'SIDE_BY_SIDE',
           size_bar_in_change_table: true,
         });
@@ -1097,8 +1097,8 @@
           }
 
           const payloadPromise = response ?
-              this._restApiHelper.readResponsePayload(response) :
-              Promise.resolve(null);
+            this._restApiHelper.readResponsePayload(response) :
+            Promise.resolve(null);
 
           return payloadPromise.then(payload => {
             if (!payload) { return null; }
@@ -1186,7 +1186,7 @@
     getChangeOrEditFiles(changeNum, patchRange) {
       if (this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME)) {
         return this.getChangeEditFiles(changeNum, patchRange).then(res =>
-            res.files);
+          res.files);
       }
       return this.getChangeFiles(changeNum, patchRange);
     },
@@ -1737,8 +1737,8 @@
         return res;
       };
       const promise = this.patchNumEquals(patchNum, this.EDIT_NAME) ?
-          this._getFileInChangeEdit(changeNum, path) :
-          this._getFileInRevision(changeNum, path, patchNum, suppress404s);
+        this._getFileInChangeEdit(changeNum, path) :
+        this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
       return promise.then(res => {
         if (!res.ok) { return res; }
@@ -2197,7 +2197,7 @@
      */
     getB64FileContents(changeId, patchNum, path, opt_parentIndex) {
       const parent = typeof opt_parentIndex === 'number' ?
-          '?parent=' + opt_parentIndex : '';
+        '?parent=' + opt_parentIndex : '';
       return this._changeBaseURL(changeId, patchNum).then(url => {
         url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
         return this._fetchB64File(url);
@@ -2256,8 +2256,8 @@
       // TODO(kaspern): For full slicer migration, app should warn with a call
       // stack every time _changeBaseURL is called without a project.
       const projectPromise = opt_project ?
-          Promise.resolve(opt_project) :
-          this.getFromProjectLookup(changeNum);
+        Promise.resolve(opt_project) :
+        this.getFromProjectLookup(changeNum);
       return projectPromise.then(project => {
         let url = `/changes/${encodeURIComponent(project)}~${changeNum}`;
         if (opt_patchNum) {
@@ -2589,9 +2589,9 @@
      */
     _getChangeURLAndSend(req) {
       const anonymizedBaseUrl = req.patchNum ?
-          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+        ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
       const anonymizedEndpoint = req.reportEndpointAsIs ?
-          req.endpoint : req.anonymizedEndpoint;
+        req.endpoint : req.anonymizedEndpoint;
 
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._restApiHelper.send({
@@ -2603,7 +2603,7 @@
           headers: req.headers,
           parseResponse: req.parseResponse,
           anonymizedUrl: anonymizedEndpoint ?
-              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
+            (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
@@ -2615,9 +2615,9 @@
      */
     _getChangeURLAndFetch(req) {
       const anonymizedEndpoint = req.reportEndpointAsIs ?
-          req.endpoint : req.anonymizedEndpoint;
+        req.endpoint : req.anonymizedEndpoint;
       const anonymizedBaseUrl = req.patchNum ?
-          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+        ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._restApiHelper.fetchJSON({
           url: url + req.endpoint,
@@ -2625,7 +2625,7 @@
           params: req.params,
           fetchOptions: req.fetchOptions,
           anonymizedUrl: anonymizedEndpoint ?
-              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
+            (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
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 ea71522..1781ce7 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
@@ -691,7 +691,7 @@
 
     test('setAccountStatus', () => {
       const sendStub = sandbox.stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve('OOO'));
+          .returns(Promise.resolve('OOO'));
       element._cache.set('/accounts/self/detail', {});
       return element.setAccountStatus('OOO').then(() => {
         assert.isTrue(sendStub.calledOnce);
@@ -702,7 +702,7 @@
             {status: 'OOO'});
         assert.deepEqual(element._restApiHelper
             ._cache.get('/accounts/self/detail'),
-           {status: 'OOO'});
+        {status: 'OOO'});
       });
     });
 
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
index 5cea96b..c9cdfb4 100644
--- 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
@@ -147,7 +147,7 @@
      */
     _logCall(req, startTime, status) {
       const method = (req.fetchOptions && req.fetchOptions.method) ?
-          req.fetchOptions.method : 'GET';
+        req.fetchOptions.method : 'GET';
       const endTime = Date.now();
       const elapsed = (endTime - startTime);
       const startAt = new Date(startTime);
@@ -339,7 +339,7 @@
         options.headers.set(
             'Content-Type', req.contentType || 'application/json');
         options.body = typeof req.body === 'string' ?
-            req.body : JSON.stringify(req.body);
+          req.body : JSON.stringify(req.body);
       }
       if (req.headers) {
         if (!options.headers) { options.headers = new Headers(); }
@@ -349,7 +349,7 @@
         }
       }
       const url = req.url.startsWith('http') ?
-          req.url : this.getBaseUrl() + req.url;
+        req.url : this.getBaseUrl() + req.url;
       const fetchReq = {
         url,
         fetchOptions: options,
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 e14b955..601f7d9 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
@@ -203,7 +203,7 @@
     messages.forEach((message, index) => {
       const messageDate = util.parseDate(message.date).getTime();
       const nextMessageDate = index === messages.length - 1 ? null :
-          util.parseDate(messages[index + 1].date).getTime();
+        util.parseDate(messages[index + 1].date).getTime();
       for (const update of updates) {
         const date = util.parseDate(update.date).getTime();
         if (date >= messageDate
@@ -211,7 +211,7 @@
           const timestamp = util.parseDate(update.date).getTime() -
               GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
           update.date = new Date(timestamp)
-            .toISOString().replace('T', ' ').replace('Z', '000000');
+              .toISOString().replace('T', ' ').replace('Z', '000000');
         }
         if (nextMessageDate && date > nextMessageDate) {
           break;
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 9ae77d9..f6ade6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -77,9 +77,9 @@
 
     _getDraftKey(location) {
       const range = location.range ?
-          `${location.range.start_line}-${location.range.start_character}` +
+        `${location.range.start_line}-${location.range.start_character}` +
               `-${location.range.end_character}-${location.range.end_line}` :
-          null;
+        null;
       let key = ['draft', location.changeNum, location.patchNum, location.path,
         location.line || ''].join(':');
       if (range) {
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 ae1de02..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
@@ -209,17 +209,17 @@
       assert.isTrue(formatSpy.lastCall.calledWithExactly(
           [{dataValue: '😂', value: '😂', match: 'tears :\')',
             text: '😂 tears :\')'},
-            {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+          {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
           ]));
     });
 
     test('_formatSuggestions', () => {
       const matchedSuggestions = [{value: '😢', match: 'tear'},
-          {value: '😂', match: 'tears'}];
+        {value: '😂', match: 'tears'}];
       element._formatSuggestions(matchedSuggestions);
       assert.deepEqual(
           [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
-          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
+            {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
           element._suggestions);
     });
 
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 fca8ae1..75b8ac3 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
@@ -74,7 +74,7 @@
      */
     RevisionInfo.prototype.getParentId = function(patchNum, parentIndex) {
       const rev = Object.values(this._change.revisions).find(rev =>
-          Gerrit.PatchSetBehavior.patchNumEquals(rev._number, patchNum));
+        Gerrit.PatchSetBehavior.patchNumEquals(rev._number, patchNum));
       return rev.commit.parents[parentIndex].commit;
     };
 
diff --git a/polygerrit-ui/app/samples/coverage-plugin.html b/polygerrit-ui/app/samples/coverage-plugin.html
index f5f9c6e..d1d96a8 100644
--- a/polygerrit-ui/app/samples/coverage-plugin.html
+++ b/polygerrit-ui/app/samples/coverage-plugin.html
@@ -39,16 +39,16 @@
 
       annotationApi.addLayer(context => {
         if (Object.keys(coverageData).length === 0) {
-           // Coverage data is not ready yet.
+          // Coverage data is not ready yet.
           return;
         }
         const path = context.path;
         const line = context.line;
-          // Highlight lines missing coverage with this background color if
-          // coverage should be displayed, else do nothing.
+        // Highlight lines missing coverage with this background color if
+        // coverage should be displayed, else do nothing.
         const annotationStyle = displayCoverage
-                         ? coverageStyle
-                         : emptyStyle;
+          ? coverageStyle
+          : emptyStyle;
         if (coverageData[path] &&
               coverageData[path].changeNum === context.changeNum &&
               coverageData[path].patchNum === context.patchNum) {
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
index fb6b5d4..0266ab9 100644
--- 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
@@ -68,7 +68,7 @@
     test('getSuggestions', done => {
       const getSuggestedAccountsStub =
           sandbox.stub(restAPI, 'getSuggestedAccounts')
-            .returns(Promise.resolve([account1, account2]));
+              .returns(Promise.resolve([account1, account2]));
 
       provider.getSuggestions('Some input').then(res => {
         assert.deepEqual(res, [account1, account2]);
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
index c83e5a2..6b97cf6 100644
--- 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
@@ -35,14 +35,15 @@
       switch (usersType) {
         case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
           return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-            input => restApi.getChangeSuggestedReviewers(changeNumber, input));
+              input =>
+                restApi.getChangeSuggestedReviewers(changeNumber, input));
         case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
           return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-            input => restApi.getChangeSuggestedCCs(changeNumber, input));
+              input => restApi.getChangeSuggestedCCs(changeNumber, input));
         case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
           return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-            input => restApi.getSuggestedAccounts(
-                `cansee:${changeNumber} ${input}`));
+              input => restApi.getSuggestedAccounts(
+                  `cansee:${changeNumber} ${input}`));
         default:
           throw new Error(`Unknown users type: ${usersType}`);
       }
@@ -65,9 +66,9 @@
         this._loggedIn = loggedIn;
       });
       this._initPromise = Promise.all([getConfigPromise, getLoggedInPromise])
-        .then(() => {
-          this._initialized = true;
-        });
+          .then(() => {
+            this._initialized = true;
+          });
       return this._initPromise;
     }
 
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index a1c3ae6..cb1fcef 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -188,6 +188,7 @@
     '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',
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 1a2d299..35bdefd 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -43,6 +43,8 @@
 	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.]*")
+	webComponentPattern   = regexp.MustCompile("webcomponentsjs-p2")
+	grAppPattern = regexp.MustCompile("gr-app-p2")
 	bundledPluginsPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_assets/[0-9.]*")
 )
 
@@ -184,9 +186,13 @@
 	buf.ReadFrom(reader)
 	original := buf.String()
 
+	// Replace the webcomponentsjs-p2 with webcomponentsjs
+	replaced := webComponentPattern.ReplaceAllString(original, "webcomponentsjs")
+	replaced = grAppPattern.ReplaceAllString(replaced, "gr-app")
+
 	// Simply remove all CDN references, so files are loaded from the local file system  or the proxy
 	// server instead.
-	replaced := cdnPattern.ReplaceAllString(original, "")
+	replaced = cdnPattern.ReplaceAllString(replaced, "")
 
 	// 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
diff --git a/prologtests/examples/BUILD b/prologtests/examples/BUILD
index f4ebe90..ebf2c68 100644
--- a/prologtests/examples/BUILD
+++ b/prologtests/examples/BUILD
@@ -1,15 +1,12 @@
 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"],
+    srcs = ["run.sh"],
+    args = ["$(JAVA)"],
+    data = glob(["*.pl"]) + [
+        "//:gerrit.war",
+        "@bazel_tools//tools/jdk:current_host_java_runtime",
+    ],
+    toolchains = ["@bazel_tools//tools/jdk:current_host_java_runtime"],
 )
diff --git a/prologtests/examples/run.sh b/prologtests/examples/run.sh
index 947c153..b2883ebe 100755
--- a/prologtests/examples/run.sh
+++ b/prologtests/examples/run.sh
@@ -1,5 +1,14 @@
 #!/bin/bash
 
+# TODO(davido): Figure out what to do if running alone and not invoked from bazel
+# $1 is equal to the $(JAVABASE)/bin/java make variable
+JAVA=$1
+
+# Checks whether or not the $1 is starting with a slash: '/' and thus considered to be
+# an absolute path. If it is, then it is left as is, if it isn't then "$PWD/ is prepended
+# (in sh_test case it is relative and thus the runfiles directory is prepended).
+[[ "$JAVA" =~ ^(/|[^/]+$) ]] || JAVA="$PWD/$JAVA"
+
 TESTS="t1 t2 t3"
 
 # Note that both t1.pl and t2.pl test code in rules.pl.
@@ -36,7 +45,7 @@
   # 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
+  echo "[$T]." | "${JAVA}" -jar ${GERRIT_WAR} prolog-shell -q -s load.pl
 
   if [ "x$?" != "x0" ]; then
     echo "### Test ${T}.pl failed."
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index b080ddf..4240a9b 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.1.0-rc3</version>
+  <version>3.2.0-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 8ce2b90..cf2b080 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.1.0-rc3</version>
+  <version>3.2.0-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 410e9db..7d3c4f0 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.1.0-rc3</version>
+  <version>3.2.0-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 f308048..9478283 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.1.0-rc3</version>
+  <version>3.2.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/version.bzl b/version.bzl
index a8988f4..fb1e5ca 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.1.0-rc3"
+GERRIT_VERSION = "3.2.0-SNAPSHOT"