Merge branch 'stable-3.1'

* stable-3.1:
  ChildProjectApiImpl: Fix error message
  PutDescription: Use present tense for commit message
  Use the same font everywhere in gr-diff
  Remove css rules for font-size-normal where not required
  Add css classes for fonts to shared styles
  Move text color default from :host rule to main.css
  Use font-family var in main.css
  Move font-family css var
  Remove theme comment about font-weight-bold
  Add css vars for line-height
  Introduce -h1 -h2 -h3 css vars for font-size
  Consolidate font-size css
  Remove redundant font:inherit styles
  Remove usage of Open Sans font family
  Replace all css spacing by variables in theme files

Change-Id: I437afcb7247bd86455dbb3d3fda00211fd5facf1
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 8dafe7e..bb4deb3 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -3748,6 +3748,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/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 6b8281a..b6afad5 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 b70dfea..c26d271 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2976,6 +2976,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
@@ -3000,6 +3308,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.
@@ -3173,6 +3485,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]]
@@ -3323,6 +3654,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]]
@@ -3428,6 +3764,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
@@ -3510,6 +3859,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/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/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/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/common/InputWithCommitMessage.java b/java/com/google/gerrit/extensions/common/InputWithCommitMessage.java
new file mode 100644
index 0000000..3e96b30
--- /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 {
+  public String commitMessage;
+
+  public InputWithCommitMessage() {
+    this(null);
+  }
+
+  public InputWithCommitMessage(@Nullable String commitMessage) {
+    this.commitMessage = commitMessage;
+  }
+}
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..6b088d3
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.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 LabelDefinitionInput {
+  public String commitMessage;
+  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/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/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a04be30..d0af686 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.extensions.common.Input;
 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;
@@ -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;
@@ -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/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/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/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/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/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/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/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/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 065facd..4ed21cc 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/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/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/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/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 48dc89f..02557cc 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;
@@ -74,6 +75,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"),
@@ -81,7 +83,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
@@ -159,6 +162,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;
 
@@ -213,6 +228,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/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/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/plugins/codemirror-editor b/plugins/codemirror-editor
index 767e6bf..92ce310 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 767e6bfc028f438b4a1234ad6179aaba78a8b11e
+Subproject commit 92ce310ecf717133601b9e824c38bc5e5eafecba
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..e60909c 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', () => {
@@ -141,7 +142,7 @@
             new URL('test/plugin.html?' + Math.random(),
                     window.location.href).toString());
         sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
-        Gerrit._setPluginsPending([]);
+        Gerrit._loadPlugins([]);
         element = createElement();
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index fe09869..caa38d9 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
@@ -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-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index f9c7798..f7d1a18 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 => {
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 6639b5a..ba4a1a9 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
@@ -118,7 +118,7 @@
     _computeItems(messages, reviewerUpdates) {
       // Polymer 2: check for undefined
       if ([messages, reviewerUpdates].some(arg => arg === undefined)) {
-        return undefined;
+        return [];
       }
 
       messages = messages || [];
@@ -155,15 +155,19 @@
     },
 
     _expandedChanged(exp) {
-      for (let i = 0; i < this._processedMessages.length; i++) {
-        this._processedMessages[i].expanded = exp;
+      if (this._processedMessages) {
+        for (let i = 0; i < this._processedMessages.length; i++) {
+          this._processedMessages[i].expanded = exp;
+        }
       }
       // _visibleMessages is a subarray of _processedMessages
       // _processedMessages contains all items from _visibleMessages
       // At this point all _visibleMessages.expanded values are set,
       // and notifyPath must be used to notify Polymer about changes.
-      for (let i = 0; i < this._visibleMessages.length; i++) {
-        this.notifyPath(`_visibleMessages.${i}.expanded`);
+      if (this._visibleMessages) {
+        for (let i = 0; i < this._visibleMessages.length; i++) {
+          this.notifyPath(`_visibleMessages.${i}.expanded`);
+        }
       }
     },
 
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..04a3294 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 1421068..70bda79 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -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/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index be0645e..0494b96 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
@@ -259,12 +259,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++;
@@ -321,12 +323,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) {
@@ -357,7 +368,7 @@
      * lines).
      * @param {!Object} state The processing state for the layer.
      */
-    _processNextLine(state) {
+    _processNextLine(state, rangesCache) {
       let baseLine;
       let revisionLine;
 
@@ -386,7 +397,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;
       }
 
@@ -395,7 +407,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/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index 6e423cd..fc2ec20 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 => {
@@ -424,7 +424,8 @@
     },
 
     _computePluginScreenName({plugin, screen}) {
-      return Gerrit._getPluginScreenName(plugin, screen);
+      if (!plugin || !screen) return '';
+      return `${plugin.getPluginName()}-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-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-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_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/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
index 17e1b1c..2263113 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -52,10 +52,7 @@
         value: () => [],
         observer: '_resetCursorStops',
       },
-      _suggestionEls: {
-        type: Array,
-        observer: '_resetCursorIndex',
-      },
+      _suggestionEls: Array,
     },
 
     behaviors: [
@@ -78,9 +75,9 @@
 
     open() {
       this.isHidden = false;
-      this.refit();
       this._resetCursorStops();
-      this._resetCursorIndex();
+      // Refit should run after we call Polymer.flush inside _resetCursorStops
+      this.refit();
     },
 
     getCurrentText() {
@@ -162,10 +159,13 @@
 
     _resetCursorStops() {
       if (this.suggestions.length > 0) {
-        Polymer.dom.flush();
-        // Polymer2: querySelectorAll returns NodeList instead of Array.
-        this._suggestionEls = Array.from(
-            this.$.suggestions.querySelectorAll('li'));
+        if (!this.isHidden) {
+          Polymer.dom.flush();
+          // Polymer2: querySelectorAll returns NodeList instead of Array.
+          this._suggestionEls = Array.from(
+              this.$.suggestions.querySelectorAll('li'));
+          this._resetCursorIndex();
+        }
       } else {
         this._suggestionEls = [];
       }
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..3456c13 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
@@ -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-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index d613640..2a05050 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -139,7 +139,7 @@
                 <span
                     class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
                     data-id$="[[link.id]]"
-                    on-tap="_handleItemTap"
+                    on-click="_handleItemTap"
                     hidden$="[[link.url]]"
                     tabindex="-1">[[link.name]]</span>
                 <a
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index ce6d043..50a20d1 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -138,10 +138,10 @@
       e.preventDefault();
       e.stopPropagation();
       if (this.$.dropdown.opened) {
-        // TODO(kaspern): This solution will not work in Shadow DOM, and
-        // is not particularly robust in general. Find a better solution
-        // when page.js has been abstracted away from components.
-        const el = this.$.cursor.target.querySelector(':not([hidden])');
+        // TODO(milutin): This solution is not particularly robust in general.
+        // Since gr-tooltip-content click on shadow dom is not propagated down,
+        // we have to target `a` inside it.
+        const el = this.$.cursor.target.querySelector(':not([hidden]) a');
         if (el) { el.click(); }
       } else {
         this._open();
@@ -175,8 +175,8 @@
      */
     _open() {
       this.$.dropdown.open();
+      this._resetCursorStops();
       this.$.cursor.setCursorAtIndex(0);
-      Polymer.dom.flush();
       this.$.cursor.target.focus();
     },
 
@@ -281,9 +281,8 @@
      * Recompute the stops for the dropdown item cursor.
      */
     _resetCursorStops() {
-      if (this.items && this.items.length > 0) {
+      if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
         Polymer.dom.flush();
-        // Polymer2: querySelectorAll returns NodeList instead of Array.
         this._listElements = Array.from(
             Polymer.dom(this.root).querySelectorAll('li'));
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index bf1c9fa..295f746 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -196,7 +196,7 @@
         MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
         assert.isTrue(element.$.dropdown.opened);
 
-        const el = element.$.cursor.target.querySelector(':not([hidden])');
+        const el = element.$.cursor.target.querySelector(':not([hidden]) a');
         const stub = sandbox.stub(el, 'click');
         MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
         assert.isTrue(stub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
index 7e7e927..3db7c63 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..03eb2e8 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
@@ -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,
   } = 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_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-loader.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
new file mode 100644
index 0000000..04caf6b
--- /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..d76c983 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
@@ -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-textarea/gr-textarea.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
index fa54dba..ad1a039 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -24,6 +24,7 @@
 <link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
 <link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 
 <dom-module id="gr-textarea">
   <template>
@@ -96,6 +97,7 @@
         max-rows="[[maxRows]]"
         value="{{text}}"
         on-bind-value-changed="_onValueChanged"></iron-autogrow-textarea>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="gr-textarea.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
index c4d31ac..f751a0d 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -82,7 +82,6 @@
       _colonIndex: Number,
       _currentSearchString: {
         type: String,
-        value: '',
         observer: '_determineSuggestions',
       },
       _hideAutocomplete: {
@@ -113,7 +112,6 @@
     },
 
     ready() {
-      this._resetEmojiDropdown();
       if (this.monospace) {
         this.classList.add('monospace');
       }
@@ -153,6 +151,7 @@
       e.stopPropagation();
       this.$.emojiSuggestions.cursorUp();
       this.$.textarea.textarea.focus();
+      this.disableEnterKeyForSelectingEmoji = false;
     },
 
     _handleDownKey(e) {
@@ -161,24 +160,34 @@
       e.stopPropagation();
       this.$.emojiSuggestions.cursorDown();
       this.$.textarea.textarea.focus();
+      this.disableEnterKeyForSelectingEmoji = false;
     },
 
     _handleEnterByKey(e) {
-      if (this._hideAutocomplete) { return; }
+      if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+        return;
+      }
       e.preventDefault();
       e.stopPropagation();
-      this.text = this._getText(this.$.emojiSuggestions.getCurrentText());
-      this._resetEmojiDropdown();
+      this._setEmoji(this.$.emojiSuggestions.getCurrentText());
     },
 
     _handleEmojiSelect(e) {
-      this.text = this._getText(e.detail.selected.dataset.value);
+      this._setEmoji(e.detail.selected.dataset.value);
+    },
+
+    _setEmoji(text) {
+      const colonIndex = this._colonIndex;
+      this.text = this._getText(text);
+      this.$.textarea.selectionStart = colonIndex + 1;
+      this.$.textarea.selectionEnd = colonIndex + 1;
+      this.$.reporting.reportInteraction('select-emoji');
       this._resetEmojiDropdown();
     },
 
     _getText(value) {
       return this.text.substr(0, this._colonIndex || 0) +
-          value + this.text.substr(this.$.textarea.selectionStart) + ' ';
+          value + this.text.substr(this.$.textarea.selectionStart);
     },
     /**
      * Uses a hidden element with the same width and styling of the textarea and
@@ -218,41 +227,41 @@
       // If cursor is not in textarea (just opened with colon as last char),
       // Don't do anything.
       if (!e.currentTarget.focused) { return; }
-      const newChar = e.detail.value[this.$.textarea.selectionStart - 1];
 
-      // When a colon is detected, set a colon index, but don't do anything else
-      // yet.
-      if (newChar === ':') {
+      const charAtCursor = e.detail && e.detail.value ?
+        e.detail.value[this.$.textarea.selectionStart - 1] : '';
+      if (charAtCursor !== ':' && this._colonIndex == null) { return; }
+
+      // When a colon is detected, set a colon index.
+      if (charAtCursor === ':') {
         this._colonIndex = this.$.textarea.selectionStart - 1;
-      // If the colon index exists, continue to determine what needs to be done
-      // with the dropdown. It may be open or closed at this point.
-      } else if (this._colonIndex !== null) {
-        // The search string is a substring of the textarea's value from (1
-        // position after) the colon index to the cursor position.
-        this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
-            this.$.textarea.selectionStart);
-        // Under the following conditions, close and reset the dropdown:
-        // - The cursor is no longer at the end of the current search string
-        // - The search string is an space or new line
-        // - The colon has been removed
-        // - There are no suggestions that match the search string
-        if (this.$.textarea.selectionStart !==
-            this._currentSearchString.length + this._colonIndex + 1 ||
-            this._currentSearchString === ' ' ||
-            this._currentSearchString === '\n' ||
-            !(e.detail.value[this._colonIndex] === ':') ||
-            !this._suggestions.length) {
-          this._resetEmojiDropdown();
-        // Otherwise open the dropdown and set the position to be just below the
-        // cursor.
-        } else if (this.$.emojiSuggestions.isHidden) {
-          this._updateCaratPosition();
-        }
-        this.$.textarea.textarea.focus();
       }
+
+      this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
+          this.$.textarea.selectionStart - this._colonIndex - 1);
+      // Under the following conditions, close and reset the dropdown:
+      // - The cursor is no longer at the end of the current search string
+      // - The search string is an space or new line
+      // - The colon has been removed
+      // - There are no suggestions that match the search string
+      if (this.$.textarea.selectionStart !==
+          this._currentSearchString.length + this._colonIndex + 1 ||
+          this._currentSearchString === ' ' ||
+          this._currentSearchString === '\n' ||
+          !(e.detail.value[this._colonIndex] === ':') ||
+          !this._suggestions.length) {
+        this._resetEmojiDropdown();
+      // Otherwise open the dropdown and set the position to be just below the
+      // cursor.
+      } else if (this.$.emojiSuggestions.isHidden) {
+        this._updateCaratPosition();
+      }
+      this.$.textarea.textarea.focus();
     },
+
     _openEmojiDropdown() {
       this.$.emojiSuggestions.open();
+      this.$.reporting.reportInteraction('open-emoji-dropdown');
     },
 
     _formatSuggestions(matchedSuggestions) {
@@ -268,11 +277,14 @@
     _determineSuggestions(emojiText) {
       if (!emojiText.length) {
         this._formatSuggestions(ALL_SUGGESTIONS);
+        this.disableEnterKeyForSelectingEmoji = true;
+      } else {
+        const matches = ALL_SUGGESTIONS.filter(suggestion => {
+          return suggestion.match.includes(emojiText);
+        }).slice(0, MAX_ITEMS_DROPDOWN);
+        this._formatSuggestions(matches);
+        this.disableEnterKeyForSelectingEmoji = false;
       }
-      const matches = ALL_SUGGESTIONS.filter(suggestion => {
-        return suggestion.match.includes(emojiText);
-      }).splice(0, MAX_ITEMS_DROPDOWN);
-      this._formatSuggestions(matches);
     },
 
     _resetEmojiDropdown() {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
index 077f4b7..854dda9 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
@@ -53,6 +53,7 @@
     setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
+      sandbox.stub(element.$.reporting, 'reportInteraction');
     });
 
     teardown(() => {
@@ -90,6 +91,21 @@
           element.$.textarea.selectionStart = 1;
           element.$.textarea.selectionEnd = 1;
           element.text = ':';
+          flushAsynchronousOperations();
+          assert.isFalse(element.$.emojiSuggestions.isHidden);
+          assert.equal(element._colonIndex, 0);
+          assert.isFalse(element._hideAutocomplete);
+          assert.equal(element._currentSearchString, '');
+        });
+
+    test('emoji selector opens when a colon is typed and some substring',
+        () => {
+          MockInteractions.focus(element.$.textarea);
+          // Needed for Safari tests. selectionStart is not updated when text is
+          // updated.
+          element.$.textarea.selectionStart = 1;
+          element.$.textarea.selectionEnd = 1;
+          element.text = ':';
           element.$.textarea.selectionStart = 2;
           element.$.textarea.selectionEnd = 2;
           element.text = ':t';
@@ -100,6 +116,30 @@
           assert.equal(element._currentSearchString, 't');
         });
 
+    test('emoji selector opens when a colon is typed in middle of text',
+        () => {
+          MockInteractions.focus(element.$.textarea);
+          // Needed for Safari tests. selectionStart is not updated when text is
+          // updated.
+          element.$.textarea.selectionStart = 1;
+          element.$.textarea.selectionEnd = 1;
+          // Since selectionStart is on Chrome set always on end of text, we
+          // stub it to 1
+          const text = ': hello';
+          sandbox.stub(element.$, 'textarea', {
+            selectionStart: 1,
+            value: text,
+            textarea: {
+              focus: () => {},
+            },
+          });
+          element.text = text;
+          flushAsynchronousOperations();
+          assert.isFalse(element.$.emojiSuggestions.isHidden);
+          assert.equal(element._colonIndex, 0);
+          assert.isFalse(element._hideAutocomplete);
+          assert.equal(element._currentSearchString, '');
+        });
     test('emoji selector closes when text changes before the colon', () => {
       const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
       MockInteractions.focus(element.$.textarea);
@@ -161,7 +201,7 @@
       const selectedItem = {dataset: {value: '😂'}};
       const event = {detail: {selected: selectedItem}};
       element._handleEmojiSelect(event);
-      assert.equal(element.text, 'test test 😂 ');
+      assert.equal(element.text, 'test test 😂');
     });
 
     test('_updateCaratPosition', () => {
@@ -236,8 +276,21 @@
         MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
         assert.isTrue(enterSpy.called);
         flushAsynchronousOperations();
-        // A space is automatically added at the end.
-        assert.equal(element.text, '💯 ');
+        assert.equal(element.text, '💯');
+      });
+
+      test('enter key - ignored on just colon without more information', () => {
+        const enterSpy = sandbox.spy(element.$.emojiSuggestions,
+            'getCursorTarget');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isFalse(enterSpy.called);
+        MockInteractions.focus(element.$.textarea);
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        flushAsynchronousOperations();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isFalse(enterSpy.called);
       });
     });
   });
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/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 8d1e543..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-rc1</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 2787353..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-rc1</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 695643c..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-rc1</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 b3e8ca5..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-rc1</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 326e26e..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-rc1"
+GERRIT_VERSION = "3.2.0-SNAPSHOT"