Merge "Allow commitLink replacements overlap"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 6575018..54a5fb3 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -824,6 +824,15 @@
 the caller needs to have the Submit permission on `refs/for/<ref>`
 (e.g. on `refs/for/refs/heads/master`).
 
+Submitting to the `refs/meta/config` branch is only allowed to project
+owners. Any explicit submit permissions for non-project-owners on this
+branch are ignored. By submitting to the `refs/meta/config` branch the
+configuration of the project is changed, which can include changes to
+the access rights of the project. Allowing this to be done by a
+non-project-owner would open a security hole enabling editing of access
+rights, and thus granting of powers beyond submitting to the
+configuration.
+
 [[category_submit_on_behalf_of]]
 === Submit (On Behalf Of)
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 0f643c9..fb1a49f 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -4829,14 +4829,19 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`value`       |optional|
+|Field Name               ||Description
+|`value`                  |optional|
 The vote that the user has given for the label. If present and zero, the
 user is permitted to vote on the label. If absent, the user is not
 permitted to vote on that label.
-|`date`        |optional|
+|`permitted_voting_range` |optional|
+The link:#voting-range-info[VotingRangeInfo] the user is authorized to vote
+on that label. If present, the user is permitted to vote on the label
+regarding the range values. If absent, the user is not permitted to vote
+on that label.
+|`date`                   |optional|
 The time and date describing when the approval was made.
-|`tag`                 |optional|
+|`tag`                    |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review.
 NOTE: To apply different tags on on different votes/comments multiple
@@ -6118,6 +6123,18 @@
 The topic will be deleted if not set.
 |===========================
 
+[[voting-range-info]]
+=== VotingRangeInfo
+The `VotingRangeInfo` entity describes the continuous voting range from min
+to max values.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`min`     |The minimum voting value.
+|`max`     |The maximum voting value.
+|======================
+
 [[web-link-info]]
 === WebLinkInfo
 The `WebLinkInfo` entity describes a link to an external site.
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index be05ea6..c52989d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -2550,6 +2550,68 @@
     assertThat(change.permittedLabels).isEmpty();
   }
 
+  @Test
+  public void maxPermittedValueAllowed() throws Exception {
+    final int minPermittedValue = -2;
+    final int maxPermittedValue = +2;
+    String heads = "refs/heads/*";
+
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    gApi.changes().id(triplet).addReviewer(user.username);
+
+    ChangeInfo c = gApi.changes()
+      .id(triplet)
+      .get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNotNull();
+    // default values
+    assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
+    assertThat(approval.permittedVotingRange.max).isEqualTo(1);
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg,
+      Permission.forLabel("Code-Review"), minPermittedValue, maxPermittedValue,
+      REGISTERED_USERS, heads);
+    saveProjectConfig(project, cfg);
+
+    c = gApi.changes()
+      .id(triplet)
+      .get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNotNull();
+    assertThat(approval.permittedVotingRange.min).isEqualTo(minPermittedValue);
+    assertThat(approval.permittedVotingRange.max).isEqualTo(maxPermittedValue);
+  }
+
+  @Test
+  public void maxPermittedValueBlocked() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    gApi.changes().id(triplet).addReviewer(user.username);
+
+    ChangeInfo c = gApi.changes()
+      .id(triplet)
+      .get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNull();
+  }
+
   private static Iterable<Account.Id> getReviewers(
       Collection<AccountInfo> r) {
     return Iterables.transform(r, a -> new Account.Id(a._accountId));
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
index d53e69a..2d3e2da 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
@@ -4,16 +4,16 @@
   group = 'rest_project',
   srcs = glob(['*IT.java']),
   deps = [
-    ':branch',
     ':project',
+    ':refassert',
   ],
   labels = ['rest'],
 )
 
 java_library(
-  name = 'branch',
+  name = 'refassert',
   srcs = [
-    'BranchAssert.java',
+    'RefAssert.java',
   ],
   deps = [
     '//lib:truth',
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
index 579171f..fbb6bde 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
@@ -4,16 +4,16 @@
   group = 'rest_project',
   srcs = glob(['*IT.java']),
   deps = [
-    ':branch',
     ':project',
+    ':refassert',
   ],
   labels = ['rest'],
 )
 
 java_library(
-  name = 'branch',
+  name = 'refassert',
   srcs = [
-    'BranchAssert.java',
+    'RefAssert.java',
   ],
   deps = [
     '//lib:truth',
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index af1383b..3f0c43e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableList;
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.RefNames;
 
@@ -107,6 +108,31 @@
     assertBranchesDeleted();
   }
 
+  @Test
+  public void missingInput() throws Exception {
+    DeleteBranchesInput input = null;
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  @Test
+  public void missingBranchList() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  @Test
+  public void emptyBranchList() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = Lists.newArrayList();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
   private String errorMessageForBranches(List<String> branches) {
     StringBuilder message = new StringBuilder();
     for (String branch : branches) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 7c98188..1305d2c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertBranches;
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -47,7 +47,7 @@
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void listBranchesOfEmptyProject() throws Exception {
-    assertBranches(ImmutableList.of(
+    assertRefs(ImmutableList.of(
           branch("HEAD", null, false),
           branch(RefNames.REFS_CONFIG,  null, false)),
         list().get());
@@ -57,7 +57,7 @@
   public void listBranches() throws Exception {
     String master = pushTo("refs/heads/master").getCommit().name();
     String dev = pushTo("refs/heads/dev").getCommit().name();
-    assertBranches(ImmutableList.of(
+    assertRefs(ImmutableList.of(
           branch("HEAD", "master", false),
           branch(RefNames.REFS_CONFIG,  null, false),
           branch("refs/heads/dev", dev, true),
@@ -72,7 +72,7 @@
     pushTo("refs/heads/dev");
     setApiUser(user);
     // refs/meta/config is hidden since user is no project owner
-    assertBranches(ImmutableList.of(
+    assertRefs(ImmutableList.of(
           branch("HEAD", "master", false),
           branch("refs/heads/master", master, false)),
         list().get());
@@ -85,7 +85,7 @@
     String dev = pushTo("refs/heads/dev").getCommit().name();
     setApiUser(user);
     // refs/meta/config is hidden since user is no project owner
-    assertBranches(ImmutableList.of(branch("refs/heads/dev", dev, false)),
+    assertRefs(ImmutableList.of(branch("refs/heads/dev", dev, false)),
         list().get());
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
similarity index 69%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
rename to gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
index 522836d..0cbf79a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
@@ -17,26 +17,26 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.RefInfo;
 
 import java.util.List;
 
-public class BranchAssert {
-  public static void assertBranches(List<BranchInfo> expectedBranches,
-      List<BranchInfo> actualBranches) {
-    assertRefNames(refs(expectedBranches), actualBranches);
-    for (int i = 0; i < expectedBranches.size(); i++) {
-      assertBranchInfo(expectedBranches.get(i), actualBranches.get(i));
+public class RefAssert {
+  public static void assertRefs(List<? extends RefInfo> expectedRefs,
+      List<? extends RefInfo> actualRefs) {
+    assertRefNames(refs(expectedRefs), actualRefs);
+    for (int i = 0; i < expectedRefs.size(); i++) {
+      assertRefInfo(expectedRefs.get(i), actualRefs.get(i));
     }
   }
 
   public static void assertRefNames(Iterable<String> expectedRefs,
-      Iterable<BranchInfo> actualBranches) {
-    Iterable<String> actualNames = refs(actualBranches);
+      Iterable<? extends RefInfo> actualRefs) {
+    Iterable<String> actualNames = refs(actualRefs);
     assertThat(actualNames).containsExactlyElementsIn(expectedRefs).inOrder();
   }
 
-  public static void assertBranchInfo(BranchInfo expected, BranchInfo actual) {
+  public static void assertRefInfo(RefInfo expected, RefInfo actual) {
     assertThat(actual.ref).isEqualTo(expected.ref);
     if (expected.revision != null) {
       assertThat(actual.revision).named("revision of " + actual.ref)
@@ -46,7 +46,7 @@
         .isEqualTo(toBoolean(expected.canDelete));
   }
 
-  private static Iterable<String> refs(Iterable<BranchInfo> infos) {
+  private static Iterable<String> refs(Iterable<? extends RefInfo> infos) {
     return Iterables.transform(infos, b -> b.ref);
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
index 77513a2..8ef1b8e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
@@ -21,7 +21,6 @@
 import java.util.Map;
 
 public class BranchInfo extends RefInfo {
-  public Boolean canDelete;
   public Map<String, ActionInfo> actions;
   public List<WebLinkInfo> webLinks;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
index 1844a76..c573600 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
@@ -17,4 +17,5 @@
 public class RefInfo {
   public String ref;
   public String revision;
+  public Boolean canDelete;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index d59e813..9125bfd 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -21,6 +21,7 @@
   public Integer value;
   public Timestamp date;
   public Boolean postSubmit;
+  public VotingRangeInfo permittedVotingRange;
 
   public ApprovalInfo(Integer id) {
     super(id);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
new file mode 100644
index 0000000..5c35a49
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class VotingRangeInfo {
+  public int min;
+  public int max;
+
+  public VotingRangeInfo(int min, int max) {
+    this.min = min;
+    this.max = max;
+  }
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index ff060b8..054bfdd 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -305,10 +305,20 @@
     public final native boolean hasValue() /*-{ return this.hasOwnProperty('value'); }-*/;
     public final native short value() /*-{ return this.value || 0; }-*/;
 
+    public final native VotingRangeInfo permittedVotingRange() /*-{ return this.permitted_voting_range; }-*/;
+
     protected ApprovalInfo() {
     }
   }
 
+  public static class VotingRangeInfo extends AccountInfo {
+    public final native short min() /*-{ return this.min || 0; }-*/;
+    public final native short max() /*-{ return this.max || 0; }-*/;
+
+    protected VotingRangeInfo() {
+    }
+  }
+
   public static class EditInfo extends JavaScriptObject {
     public final native String name() /*-{ return this.name; }-*/;
     public final native String setName(String n) /*-{ this.name = n; }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
index 36107ee..779c32b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
@@ -36,8 +36,20 @@
 
 class Actions extends Composite {
   private static final String[] CORE = {
-    "abandon", "cherrypick", "followup", "hashtags", "publish",
-    "rebase", "restore", "revert", "submit", "topic", "/",};
+    "abandon",
+    "assignee",
+    "cherrypick",
+    "description",
+    "followup",
+    "hashtags",
+    "publish",
+    "rebase",
+    "restore",
+    "revert",
+    "submit",
+    "topic",
+    "/",
+  };
 
   interface Binder extends UiBinder<FlowPanel, Actions> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
index e0c252c..6b4cf36 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
@@ -255,6 +255,7 @@
     Map<Integer, VotableInfo> d = new HashMap<>();
     for (String name : change.labels()) {
       LabelInfo label = change.label(name);
+      short labelMaxValue = LabelInfo.parseValue(label.maxValue());
       if (label.all() != null) {
         for (ApprovalInfo ai : Natives.asList(label.all())) {
           int id = ai._accountId();
@@ -263,7 +264,10 @@
             ad = new VotableInfo();
             d.put(id, ad);
           }
-          if (ai.hasValue()) {
+          if (ai.permittedVotingRange() != null
+              && ai.permittedVotingRange().max() == labelMaxValue) {
+            ad.votable(name + " (" + label.maxValue() + ") ");
+          } else if (ai.hasValue()) {
             ad.votable(name);
           }
         }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
index 3f471bf..b39e2a2 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.common.data.ProjectAccess;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -76,13 +75,14 @@
   }
 
   @Override
-  protected ProjectAccess updateProjectConfig(CurrentUser user,
+  protected ProjectAccess updateProjectConfig(ProjectControl projectControl,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException {
     RevCommit commit = config.commit(md);
 
     gitRefUpdated.fire(config.getProject().getNameKey(), RefNames.REFS_CONFIG,
-        base, commit.getId(), user.asIdentifiedUser().getAccount());
+        base, commit.getId(),
+        projectControl.getUser().asIdentifiedUser().getAccount());
 
     projectCache.evict(config.getProject());
     return projectAccessFactory.create(projectName).call();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index bd88e6a..69ec6d9 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -206,8 +206,8 @@
 
     detail.setLocal(local);
     detail.setOwnerOf(ownerOf);
-    detail.setCanUpload(pc.isOwner()
-        || (metaConfigControl.isVisible() && metaConfigControl.canUpload()));
+    detail.setCanUpload(metaConfigControl.isVisible()
+        && (pc.isOwner() || metaConfigControl.canUpload()));
     detail.setConfigVisible(pc.isOwner() || metaConfigControl.isVisible());
     detail.setGroupInfo(buildGroupInfo(local));
     detail.setLabelTypes(pc.getLabelTypes());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index 111dfc9..4c7d257 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.rpc.Handler;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -163,17 +162,17 @@
         md.setMessage("Modify access rules\n");
       }
 
-      return updateProjectConfig(projectControl.getUser(), config, md,
+      return updateProjectConfig(projectControl, config, md,
           parentProjectUpdate);
     } catch (RepositoryNotFoundException notFound) {
       throw new NoSuchProjectException(projectName);
     }
   }
 
-  protected abstract T updateProjectConfig(CurrentUser user,
+  protected abstract T updateProjectConfig(ProjectControl projectControl,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException,
-      OrmException;
+      OrmException, PermissionDeniedException;
 
   private void replace(ProjectConfig config, Set<String> toDelete,
       AccessSection section) throws NoSuchGroupException {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
index 9260e01..966cd88 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeInserter;
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.project.SetParent;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -106,9 +107,20 @@
   }
 
   @Override
-  protected Change.Id updateProjectConfig(CurrentUser user,
+  protected Change.Id updateProjectConfig(ProjectControl projectControl,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
-      throws IOException, OrmException {
+          throws IOException, OrmException, PermissionDeniedException {
+    RefControl refsMetaConfigControl =
+        projectControl.controlForRef(RefNames.REFS_CONFIG);
+    if (!refsMetaConfigControl.isVisible()) {
+      throw new PermissionDeniedException(
+          RefNames.REFS_CONFIG + " not visible");
+    }
+    if (!projectControl.isOwner() && !refsMetaConfigControl.canUpload()) {
+      throw new PermissionDeniedException(
+          "cannot upload to " + RefNames.REFS_CONFIG);
+    }
+
     md.setInsertChangeId(true);
     Change.Id changeId = new Change.Id(seq.nextChangeId());
     RevCommit commit =
@@ -120,9 +132,9 @@
 
     try (RevWalk rw = new RevWalk(md.getRepository());
         ObjectInserter objInserter = md.getRepository().newObjectInserter();
-        BatchUpdate bu = updateFactory.create(
-          db, config.getProject().getNameKey(), user,
-          TimeUtil.nowTs())) {
+        BatchUpdate bu =
+            updateFactory.create(db, config.getProject().getNameKey(),
+                projectControl.getUser(), TimeUtil.nowTs())) {
       bu.setRepository(md.getRepository(), rw, objInserter);
       bu.insertChange(
           changeInserterFactory.create(changeId, commit, RefNames.REFS_CONFIG)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index d480df9..d924b33 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -45,6 +45,7 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.LinkedHashMultimap;
@@ -54,6 +55,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -75,6 +77,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.VotingRangeInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -602,7 +605,7 @@
     LabelTypes labelTypes = ctl.getLabelTypes();
     Map<String, LabelWithStatus> withStatus = cd.change().getStatus().isOpen()
       ? labelsForOpenChange(ctl, cd, labelTypes, standard, detailed)
-      : labelsForClosedChange(cd, labelTypes, standard, detailed);
+      : labelsForClosedChange(ctl, cd, labelTypes, standard, detailed);
     return ImmutableMap.copyOf(
         Maps.transformValues(withStatus, LabelWithStatus::label));
   }
@@ -722,6 +725,8 @@
     for (Account.Id accountId : allUsers) {
       IdentifiedUser user = userFactory.create(accountId);
       ChangeControl ctl = baseCtrl.forUser(user);
+      Map<String, VotingRangeInfo> pvr =
+        getPermittedVotingRanges(permittedLabels(ctl, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         LabelType lt = ctl.getLabelTypes().byLabel(e.getKey());
         if (lt == null) {
@@ -730,6 +735,8 @@
           continue;
         }
         Integer value;
+        VotingRangeInfo permittedVotingRange =
+          pvr.getOrDefault(lt.getName(), null);
         String tag = null;
         Timestamp date = null;
         PatchSetApproval psa = current.get(accountId, lt.getName());
@@ -753,19 +760,53 @@
           value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
         }
         addApproval(e.getValue().label(),
-            approvalInfo(accountId, value, tag, date));
+            approvalInfo(accountId, value, permittedVotingRange, tag, date));
       }
     }
   }
 
+  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
+      Map<String, Collection<String>> permittedLabels) {
+    Map<String, VotingRangeInfo> permittedVotingRanges =
+      Maps.newHashMapWithExpectedSize(permittedLabels.size());
+    for (String label : permittedLabels.keySet()) {
+      List<Integer> permittedVotingRange = permittedLabels.get(label)
+        .stream()
+        .map(this::parseRangeValue)
+        .filter(java.util.Objects::nonNull)
+        .sorted()
+        .collect(toList());
+
+      if (permittedVotingRange.isEmpty()) {
+        permittedVotingRanges.put(label, null);
+      } else {
+        int minPermittedValue = permittedVotingRange.get(0);
+        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
+        permittedVotingRanges.put(label,
+          new VotingRangeInfo(minPermittedValue, maxPermittedValue));
+      }
+    }
+    return permittedVotingRanges;
+  }
+
+  private Integer parseRangeValue(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    } else if (value.startsWith(" ")) {
+      value = value.trim();
+    }
+    return Ints.tryParse(value);
+  }
+
   private Timestamp getSubmittedOn(ChangeData cd)
       throws OrmException {
     Optional<PatchSetApproval> s = cd.getSubmitApproval();
     return s.isPresent() ? s.get().getGranted() : null;
   }
 
-  private Map<String, LabelWithStatus> labelsForClosedChange(ChangeData cd,
-      LabelTypes labelTypes, boolean standard, boolean detailed)
+  private Map<String, LabelWithStatus> labelsForClosedChange(
+      ChangeControl baseCtrl, ChangeData cd, LabelTypes labelTypes,
+      boolean standard, boolean detailed)
       throws OrmException {
     Set<Account.Id> allUsers = new HashSet<>();
     if (detailed) {
@@ -831,10 +872,12 @@
     for (Account.Id accountId : allUsers) {
       Map<String, ApprovalInfo> byLabel =
           Maps.newHashMapWithExpectedSize(labels.size());
-
+      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
+        ChangeControl ctl = baseCtrl.forUser(userFactory.create(accountId));
+        pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountId, 0, null, null);
+          ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
           byLabel.put(entry.getKey(), ai);
           addApproval(entry.getValue().label(), ai);
         }
@@ -849,6 +892,7 @@
         ApprovalInfo info = byLabel.get(type.getName());
         if (info != null) {
           info.value = Integer.valueOf(val);
+          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
           info.date = psa.getGranted();
           info.tag = psa.getTag();
           if (psa.isPostSubmit()) {
@@ -865,17 +909,18 @@
     return labels;
   }
 
-  private ApprovalInfo approvalInfo(Account.Id id, Integer value, String tag,
-      Timestamp date) {
-    ApprovalInfo ai = getApprovalInfo(id, value, tag, date);
+  private ApprovalInfo approvalInfo(Account.Id id, Integer value,
+      VotingRangeInfo permittedVotingRange, String tag, Timestamp date) {
+    ApprovalInfo ai = getApprovalInfo(id, value, permittedVotingRange, tag, date);
     accountLoader.put(ai);
     return ai;
   }
 
-  public static ApprovalInfo getApprovalInfo(
-      Account.Id id, Integer value, String tag, Timestamp date) {
+  public static ApprovalInfo getApprovalInfo(Account.Id id, Integer value,
+      VotingRangeInfo permittedVotingRange, String tag, Timestamp date) {
     ApprovalInfo ai = new ApprovalInfo(id.get());
     ai.value = value;
+    ai.permittedVotingRange = permittedVotingRange;
     ai.date = date;
     ai.tag = tag;
     return ai;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 5e4eb6f..162a6b1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -93,7 +93,7 @@
     for (Map.Entry<String, Short> e : approvals.entrySet()) {
       Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
       result.put(e.getKey(),
-          ChangeJson.getApprovalInfo(a.getId(), value, null, ts));
+          ChangeJson.getApprovalInfo(a.getId(), value, null, null, ts));
     }
     return result;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 7c841c5..ab7eb0e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -84,7 +84,7 @@
     this.db = db;
   }
 
-  boolean isStale(Change.Id id) throws IOException, OrmException {
+  public boolean isStale(Change.Id id) throws IOException, OrmException {
     ChangeIndex i = indexes.getSearchIndex();
     if (i == null) {
       return false; // No index; caller couldn't do anything if it is stale.
@@ -100,17 +100,24 @@
       return true; // Not in index, but caller wants it to be.
     }
     ChangeData cd = result.get();
-    if (reviewDbChangeIsStale(
-        cd.change(),
-        ChangeNotes.readOneReviewDbChange(db.get(), cd.getId()))) {
-      return true;
-    }
+    return isStale(repoManager, id, cd.change(),
+        ChangeNotes.readOneReviewDbChange(db.get(), id),
+        parseStates(cd), parsePatterns(cd));
+  }
 
-    return isStale(repoManager, id, parseStates(cd), parsePatterns(cd));
+  public static boolean isStale(
+      GitRepositoryManager repoManager,
+      Change.Id id,
+      Change indexChange,
+      @Nullable Change reviewDbChange,
+      SetMultimap<Project.NameKey, RefState> states,
+      Multimap<Project.NameKey, RefStatePattern> patterns) {
+    return reviewDbChangeIsStale(indexChange, reviewDbChange)
+        || refsAreStale(repoManager, id, states, patterns);
   }
 
   @VisibleForTesting
-  static boolean isStale(GitRepositoryManager repoManager,
+  static boolean refsAreStale(GitRepositoryManager repoManager,
       Change.Id id,
       SetMultimap<Project.NameKey, RefState> states,
       Multimap<Project.NameKey, RefStatePattern> patterns) {
@@ -118,7 +125,7 @@
         Sets.union(states.keySet(), patterns.keySet());
 
     for (Project.NameKey p : projects) {
-      if (isStale(repoManager, id, p, states, patterns)) {
+      if (refsAreStale(repoManager, id, p, states, patterns)) {
         return true;
       }
     }
@@ -145,8 +152,7 @@
     return parseStates(cd.getRefStates());
   }
 
-  @VisibleForTesting
-  static SetMultimap<Project.NameKey, RefState> parseStates(
+  public static SetMultimap<Project.NameKey, RefState> parseStates(
       Iterable<byte[]> states) {
     RefState.check(states != null, null);
     SetMultimap<Project.NameKey, RefState> result = HashMultimap.create();
@@ -188,7 +194,7 @@
     return result;
   }
 
-  private static boolean isStale(GitRepositoryManager repoManager,
+  private static boolean refsAreStale(GitRepositoryManager repoManager,
       Change.Id id, Project.NameKey project,
       SetMultimap<Project.NameKey, RefState> allStates,
       Multimap<Project.NameKey, RefStatePattern> allPatterns) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
index f4fa446..bcaf79a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
@@ -16,10 +16,11 @@
 
 import static java.lang.String.format;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.IdentifiedUser;
@@ -72,13 +73,10 @@
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
-      throws OrmException, IOException, ResourceConflictException {
+      throws OrmException, IOException, RestApiException {
 
-    if (input == null) {
-      input = new DeleteBranchesInput();
-    }
-    if (input.branches == null) {
-      input.branches = Lists.newArrayListWithCapacity(1);
+    if (input == null || input.branches == null || input.branches.isEmpty()) {
+      throw new BadRequestException("branches must be specified");
     }
 
     try (Repository r = repoManager.openRepository(project.getNameKey())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index 01b4bc1..085f34c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -765,11 +765,12 @@
   }
 
   public Change reloadChange() throws OrmException {
-    notes = notesFactory.create(db, project, legacyId);
-    change = notes.getChange();
-    if (change == null) {
-      throw new OrmException("Unable to load change " + legacyId);
+    try {
+      notes = notesFactory.createChecked(db, project, legacyId);
+    } catch (NoSuchChangeException e) {
+      throw new OrmException("Unable to load change " + legacyId, e);
     }
+    change = notes.getChange();
     setPatchSets(null);
     return change;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index cdd1e07..913ce93 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
-import static com.google.gerrit.server.index.change.StalenessChecker.isStale;
+import static com.google.gerrit.server.index.change.StalenessChecker.refsAreStale;
 import static com.google.gerrit.testutil.TestChanges.newChange;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
@@ -189,7 +189,7 @@
 
     // Not stale.
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name()),
@@ -199,7 +199,7 @@
 
     // Wrong ref value.
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, SHA1),
@@ -209,7 +209,7 @@
 
     // Swapped repos.
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id2.name()),
@@ -222,7 +222,7 @@
     ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
     tr1.update(ref3, id3);
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name()),
@@ -232,7 +232,7 @@
 
     // Ignore ref not mentioned.
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name())),
@@ -241,7 +241,7 @@
 
     // One ref wrong.
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name()),
@@ -257,7 +257,7 @@
 
     // ref1 is only ref matching pattern.
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name())),
@@ -269,7 +269,7 @@
     String ref2 = "refs/heads/bar";
     ObjectId id2 = tr1.update(ref2, tr1.commit().message("commit 2"));
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name())),
@@ -277,7 +277,7 @@
                     P1, RefStatePattern.create("refs/heads/*"))))
         .isTrue();
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name()),
@@ -295,7 +295,7 @@
 
     // ref1 is only ref matching pattern.
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name())),
@@ -307,7 +307,7 @@
     String ref3 = "refs/other/foo";
     ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name())),
@@ -315,7 +315,7 @@
                     P1, RefStatePattern.create("refs/*/foo"))))
         .isTrue();
     assertThat(
-            isStale(
+            refsAreStale(
                 repoManager, C,
                 ImmutableSetMultimap.of(
                     P1, RefState.create(ref1, id1.name()),
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 34f2672..4eee495 100644
--- a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -25,12 +25,12 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.restapi.Url;
 
-import org.apache.http.client.utils.DateUtils;
-
 import java.io.BufferedReader;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.security.Principal;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Enumeration;
@@ -55,6 +55,8 @@
 /** Simple fake implementation of {@link HttpServletRequest}. */
 public class FakeHttpServletRequest implements HttpServletRequest {
   public static final String SERVLET_PATH = "/b";
+  public static final DateTimeFormatter rfcDateformatter =
+      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
 
   private final Map<String, Object> attributes;
   private final ListMultimap<String, String> headers;
@@ -263,7 +265,8 @@
   @Override
   public long getDateHeader(String name) {
     String v = getHeader(name);
-    return v != null ? DateUtils.parseDate(v).getTime() : 0;
+    return v == null ? 0 :
+        rfcDateformatter.parse(v, Instant::from).getEpochSecond();
   }
 
   @Override
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 83e182a..ce17b3a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -20,6 +20,7 @@
 <link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
 
@@ -145,11 +146,20 @@
     <section>
       <span class="title">Topic</span>
       <span class="value">
-        <gr-editable-label
-            value="{{change.topic}}"
-            placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-            read-only="[[_topicReadOnly]]"
-            on-changed="_handleTopicChanged"></gr-editable-label>
+        <template is="dom-if" if="[[change.topic]]">
+          <gr-linked-chip
+              text="[[change.topic]]"
+              href="[[_computeTopicHref(change.topic)]]"
+              removable
+              on-remove="_handleTopicRemoved"></gr-linked-chip>
+        </template>
+        <template is="dom-if" if="[[!change.topic]]">
+          <gr-editable-label
+              value="{{change.topic}}"
+              placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+              read-only="[[_topicReadOnly]]"
+              on-changed="_handleTopicChanged"></gr-editable-label>
+        </template>
       </span>
     </section>
     <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 01ef473..e458068 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -172,5 +172,14 @@
       }
       return output;
     },
+
+    _computeTopicHref: function(topic) {
+      return '/q/topic:' + encodeURIComponent(encodeURIComponent(topic)) +
+          '+(status:open OR status:merged)';
+    },
+
+    _handleTopicRemoved: function() {
+      this.set(['change', 'topic'], '');
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 7fa4744..04cc212 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
@@ -173,6 +173,14 @@
         element._handleTopicChanged({}, 'the new topic');
         assert.isTrue(topicStub.calledWith('the id', 'the new topic'));
       });
+
+      test('clicking x on topic chip removes topic', function() {
+        sandbox.stub(element, '_handleTopicChanged');
+        flushAsynchronousOperations();
+        var remove = element.$$('gr-linked-chip').$.remove;
+        MockInteractions.tap(remove);
+        assert.equal(element.change.topic, '');
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index 1d31c12..ceaf02d 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -17,21 +17,12 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-account-dropdown">
   <template>
     <style>
-      :host {
-        display: inline-block;
-      }
-      .dropdown-trigger {
-        text-decoration: none;
-      }
-      .dropdown-content {
-        background-color: #fff;
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
-      }
       button {
         background: none;
         border: none;
@@ -43,51 +34,15 @@
         width: 2em;
         vertical-align: middle;
       }
-      ul {
-        list-style: none;
-      }
-      ul .accountName {
-        font-weight: bold;
-      }
-      li .accountInfo,
-      li a {
-        display: block;
-        padding: .85em 1em;
-      }
-      li a:link,
-      li a:visited {
-        color: #00e;
-        text-decoration: none;
-      }
-      li a:hover {
-        background-color: #6B82D6;
-        color: #fff;
-      }
     </style>
-    <gr-button link class="dropdown-trigger" id="trigger"
-        on-tap="_showDropdownTapHandler">
-      <span hidden$="[[_hasAvatars]]" hidden>[[account.name]]</span>
-      <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
-          image-size="56"></gr-avatar>
-    </gr-button>
-    <iron-dropdown id="dropdown"
-        vertical-align="top"
-        vertical-offset="25"
+    <gr-dropdown items=[[links]] top-content=[[topContent]]
         horizontal-align="right">
-      <div class="dropdown-content">
-        <ul>
-          <li>
-            <div class="accountInfo">
-              <div class="accountName">[[account.name]]</div>
-              <div>[[account.email]]</div>
-            </div>
-          </li>
-          <li><a href$="[[_computeRelativeURL('/settings')]]">Settings</a></li>
-          <li><a href$="[[_computeRelativeURL('/switch-account')]]">Switch account</a></li>
-          <li><a href$="[[_computeRelativeURL('/logout')]]">Sign out</a></li>
-        </ul>
-      </div>
-    </iron-dropdown>
+      <gr-button link class="dropdown-trigger" id="trigger">
+        <span hidden$="[[_hasAvatars]]" hidden>[[account.name]]</span>
+        <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
+            image-size="56"></gr-avatar>
+      </gr-button>
+    </gr-dropdown>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-account-dropdown.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index ad944dc..4011135 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -20,26 +20,31 @@
     properties: {
       account: Object,
       _hasAvatars: Boolean,
+      links: {
+        type: Array,
+        value: [
+          {name: 'Settings', url: '/settings'},
+          {name: 'Switch account', url: '/switch-account'},
+          {name: 'Sign out', url: '/logout'},
+        ],
+      },
+      topContent: {
+        type: Array,
+        computed: '_getTopContent(account)',
+      },
     },
 
     attached: function() {
       this.$.restAPI.getConfig().then(function(cfg) {
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
       }.bind(this));
-
-      this.listen(this.$.dropdown, 'tap', '_handleDropdownTap');
     },
 
-    _handleDropdownTap: function(e) {
-      this.$.dropdown.close();
-    },
-
-    _showDropdownTapHandler: function(e) {
-      this.$.dropdown.open();
-    },
-
-    _computeRelativeURL: function(path) {
-      return '//' + window.location.host + path;
+    _getTopContent: function(account) {
+      return [
+        {text: account.name, bold: true},
+        {text: account.email},
+      ];
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 8c39da8..ec9141f 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -41,11 +41,10 @@
       element = fixture('basic');
     });
 
-    test('tap on trigger opens menu', function() {
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isTrue(element.$.dropdown.opened);
+    test('account information', function() {
+      element.account = {name: 'John Doe', email: 'john@doe.com'};
+      assert.deepEqual(element.topContent,
+          [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index 3e30810..76a1c1f 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -17,8 +17,8 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
 <link rel="import" href="../gr-account-dropdown/gr-account-dropdown.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../gr-search-bar/gr-search-bar.html">
 
 <dom-module id="gr-main-header">
@@ -42,12 +42,6 @@
       ul {
         list-style: none;
       }
-      .links {
-        margin-left: 1em;
-      }
-      .links .menuContainer {
-        display: none;
-      }
       .links > li {
         cursor: default;
         display: inline-block;
@@ -55,31 +49,8 @@
         padding: .5em 0;
         position: relative;
       }
-      .links li:hover .menuContainer,
-      .links li:active .menuContainer {
-        background-color: #fff;
-        border-radius: 3px;
-        box-shadow: 0 1px 1px rgba(0, 0, 0, .3);
-        display: block;
-        left: -.5em;
-        padding: .5em 0;
-        position: absolute;
-        top: 2.4em;
-        z-index: 1000;
-      }
-      .links li ul li a:link,
-      .links li ul li a:visited {
-        color: #00e;
-        display: block;
-        padding: .3em 1em;
-        text-decoration: none;
-        white-space: nowrap;
-      }
-      .links li ul li:hover a,
-      .links li ul li:active a {
-        background-color: var(--selection-background-color);
-      }
       .linksTitle {
+        color: black;
         display: inline-block;
         padding-right: 1em;
         position: relative;
@@ -121,6 +92,13 @@
         overflow: hidden;
         text-overflow: ellipsis;
       }
+      .dropdown-trigger {
+        text-decoration: none;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
       @media screen and (max-width: 50em) {
         .bigTitle {
           font-size: 14px;
@@ -139,16 +117,13 @@
       <ul class="links">
         <template is="dom-repeat" items="[[_links]]" as="linkGroup">
           <li>
-            <span class="linksTitle">
+          <gr-dropdown
+              items = [[linkGroup.links]]
+              horizontal-align="left">
+            <span class="linksTitle" id="[[linkGroup.title]]">
               [[linkGroup.title]] <i class="downArrow"></i>
             </span>
-            <div class="menuContainer">
-              <ul>
-                <template is="dom-repeat" items="[[linkGroup.links]]" as="link">
-                  <li><a href$="[[link.url]]">[[link.name]]</a></li>
-                </template>
-              </ul>
-            </div>
+          </gr-dropdown>
           </li>
         </template>
       </ul>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 2f017de..ebeb9af 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -36,7 +36,7 @@
     is: 'gr-main-header',
 
     hostAttributes: {
-      role: 'banner'
+      role: 'banner',
     },
 
     properties: {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 8905854..72138ff 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -125,12 +125,7 @@
       this.comment.message = this._messageText;
       this.disabled = true;
 
-      this.$.storage.eraseDraftComment({
-        changeNum: this.changeNum,
-        patchNum: this.patchNum,
-        path: this.comment.path,
-        line: this.comment.line,
-      });
+      this._eraseDraftComment();
 
       this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
         this.disabled = false;
@@ -155,6 +150,15 @@
       }.bind(this));
     },
 
+    _eraseDraftComment: function() {
+      this.$.storage.eraseDraftComment({
+        changeNum: this.changeNum,
+        patchNum: this.patchNum,
+        path: this.comment.path,
+        line: this.comment.line,
+      });
+    },
+
     _commentChanged: function(comment) {
       this.editing = !!comment.__editing;
       if (this.editing) { // It's a new draft/reply, notify.
@@ -338,6 +342,8 @@
       }
       this.editing = false;
       this.disabled = true;
+      this._eraseDraftComment();
+
       if (!this.comment.id) {
         this.disabled = false;
         this._fireDiscard();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index 96caebf..f562d3a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -182,6 +182,7 @@
 
   suite('gr-diff-comment draft tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
       stub('gr-rest-api-interface', {
@@ -219,6 +220,11 @@
         path: '/path/to/file',
         line: 5,
       };
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
     });
 
     test('button visibility states', function() {
@@ -332,6 +338,8 @@
       assert.isTrue(element.editing);
 
       element._messageText = '';
+      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+
       // Save should be disabled on an empty message.
       var disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
@@ -345,18 +353,30 @@
       var numDiscardEvents = 0;
       element.addEventListener('comment-discard', function(e) {
         numDiscardEvents++;
-        if (numDiscardEvents == 3) {
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
           assert.isFalse(updateStub.called);
           done();
         }
       });
       MockInteractions.tap(element.$$('.cancel'));
-      MockInteractions.tap(element.$$('.discard'));
       element.flushDebouncer('fire-update');
       element._messageText = '';
       MockInteractions.pressAndReleaseKeyOn(element.$.editTextarea, 27); // esc
     });
 
+    test('draft discard removes message from storage', function(done) {
+      element._messageText = '';
+      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+
+      var numDiscardEvents = 0;
+      element.addEventListener('comment-discard', function(e) {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+      MockInteractions.tap(element.$$('.discard'));
+    });
+
     test('ctrl+s saves comment', function(done) {
       var stub = sinon.stub(element, 'save', function() {
         assert.isTrue(stub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
new file mode 100644
index 0000000..00d50e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -0,0 +1,112 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-dropdown">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+      }
+      .dropdown-trigger {
+        text-decoration: none;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
+      button {
+        background: none;
+        border: none;
+        font: inherit;
+        padding: .3em 0;
+      }
+      gr-avatar {
+        height: 2em;
+        width: 2em;
+        vertical-align: middle;
+      }
+      ul {
+        list-style: none;
+      }
+      ul .accountName {
+        font-weight: bold;
+      }
+      li .accountInfo,
+      li a {
+        display: block;
+        padding: .85em 1em;
+      }
+      li a:link,
+      li a:visited {
+        color: #00e;
+        text-decoration: none;
+      }
+      li a:hover {
+        background-color: #6B82D6;
+        color: #fff;
+      }
+      .topContent {
+        display: block;
+        padding: .85em 1em;
+      }
+      .bold-text {
+        font-weight: bold;
+      }
+    </style>
+    <gr-button link class="dropdown-trigger" id="trigger"
+        on-tap="_showDropdownTapHandler">
+      <content></content>
+    </gr-button>
+    <iron-dropdown id="dropdown"
+        vertical-align="top"
+        vertical-offset="25"
+        horizontal-align="[[horizontalAlign]]"
+        on-tap="_handleDropdownTap">
+      <div class="dropdown-content">
+        <ul>
+          <template is="dom-if" if="[[topContent]]">
+            <div class="topContent">
+              <template
+                  is="dom-repeat"
+                  items="[[topContent]]"
+                  as="item"
+                  initial-count="75">
+                <div class$="[[_getClassIfBold(item.bold)]] top-item">
+                  [[item.text]]
+                </div>
+              </template>
+            </div>
+          </template>
+          <template
+              is="dom-repeat"
+              items="[[items]]"
+              as="link"
+              initial-count="75">
+            <li><a href$="[[_computeRelativeURL(link.url)]]">[[link.name]]</a>
+            </li>
+          </template>
+        </ul>
+      </div>
+    </iron-dropdown>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-dropdown.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
new file mode 100644
index 0000000..d10d219
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -0,0 +1,57 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-dropdown',
+
+    properties: {
+      items: Array,
+      topContent: Object,
+      horizontalAlign: {
+        type: String,
+        value: 'left',
+      },
+      _hasAvatars: String,
+    },
+
+    attached: function() {
+      this.$.restAPI.getConfig().then(function(cfg) {
+        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+      }.bind(this));
+    },
+
+    _handleDropdownTap: function(e) {
+      this.$.dropdown.close();
+    },
+
+    _showDropdownTapHandler: function(e) {
+      this.$.dropdown.open();
+    },
+
+    _getClassIfBold: function(bold) {
+      return bold ? 'bold-text' : '';
+    },
+
+    _computeURLHelper: function(host, path) {
+      return '//' + host + path;
+    },
+
+    _computeRelativeURL: function(path) {
+      var host = window.location.host;
+      return this._computeURLHelper(host, path);
+    },
+  });
+})();
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
new file mode 100644
index 0000000..b2f2d21
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -0,0 +1,74 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-dropdown</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-dropdown.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-dropdown></gr-dropdown>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-dropdown tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+    });
+
+    test('tap on trigger opens menu', function() {
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(element.$.trigger);
+      assert.isTrue(element.$.dropdown.opened);
+    });
+
+    test('_computeRelativeURL', function() {
+      var path = '/test';
+      var host = 'http://www.testsite.com';
+      var computedPath = element._computeURLHelper(host, path);
+      assert.equal(computedPath, '//http://www.testsite.com/test');
+    });
+
+    test('_getClassIfBold', function() {
+      var bold = true;
+      assert.equal(element._getClassIfBold(bold), 'bold-text');
+
+      bold = false;
+      assert.equal(element._getClassIfBold(bold), '');
+    });
+
+    test('Top text exists and is bolded correctly', function() {
+      element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
+      flushAsynchronousOperations();
+      var topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
+      assert.equal(topItems.length, 2);
+      assert.isTrue(topItems[0].classList.contains('bold-text'));
+      assert.isFalse(topItems[1].classList.contains('bold-text'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
new file mode 100644
index 0000000..a3d2769
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -0,0 +1,66 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-button/gr-button.html">
+<dom-module id="gr-linked-chip">
+  <template>
+    <style>
+      :host {
+        display: block;
+        overflow: hidden;
+      }
+      .container {
+        align-items: center;
+        background: #eee;
+        border-radius: .75em;
+        display: inline-flex;
+        padding: 0 .5em;
+      }
+      gr-button.remove,
+      gr-button.remove:hover,
+      gr-button.remove:focus {
+        border-color: transparent;
+        color: #333;
+      }
+      gr-button.remove {
+        background: #eee;
+        color: #666;
+        font-size: 1.7em;
+        font-weight: normal;
+        height: .6em;
+        line-height: .6em;
+        margin-left: .15em;
+        padding: 0;
+        text-decoration: none;
+      }
+      .transparentBackground,
+      gr-button.transparentBackground {
+        background-color: transparent;
+      }
+    </style>
+    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
+      <a href$="[[href]]">[[text]]</a>
+      <gr-button
+          id="remove"
+          hidden$="[[!removable]]"
+          hidden
+          class$="remove [[_getBackgroundClass(transparentBackground)]]"
+          on-tap="_handleRemoveTap">×</gr-button>
+    </div>
+  </template>
+  <script src="gr-linked-chip.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
new file mode 100644
index 0000000..c6a5e4e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -0,0 +1,42 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-linked-chip',
+
+    properties: {
+      href: String,
+      removable: {
+        type: Boolean,
+        value: false,
+      },
+      text: String,
+      transparentBackground: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    _getBackgroundClass: function(transparent) {
+      return transparent ? 'transparentBackground' : '';
+    },
+
+    _handleRemoveTap: function(e) {
+      e.preventDefault();
+      this.fire('remove');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
new file mode 100644
index 0000000..5e2cac5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-linked-chip</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-linked-chip.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-linked-chip></gr-linked-chip>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-linked-chip tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('remove fired', function() {
+      var spy = sandbox.spy();
+      element.addEventListener('remove', spy);
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.$.remove);
+      assert.isTrue(spy.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index c187cf2..8a66533 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -68,7 +68,7 @@
     REVIEWER_UPDATES: 19,
 
     // Set the submittable boolean.
-    SUBMITTABLE: 20
+    SUBMITTABLE: 20,
   };
 
   Polymer({
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 7574d18..48ddaf2 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -94,6 +94,7 @@
     'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+    'shared/gr-linked-chip/gr-linked-chip_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
     'shared/gr-select/gr-select_test.html',