Merge "Low-level plugin event helper API"
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 40906bd..99b45a2 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -1095,8 +1095,8 @@
 
 Note that a new label as `Is-Pure-Revert` should not be configured.
 It's only used to show `'Needs Is-Pure-Revert'` in the UI to clearly
-indicate to the user that all the comments have to be resolved for the
-change to become submittable.
+indicate to the user that the change has to be a pure revert in order
+to become submittable.
 
 == Examples - Submit Type
 The following examples show how to implement own submit type rules.
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index cb33959..8ac063b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -467,7 +467,7 @@
   }
 
   @Test
-  public void ignoreChange() throws Exception {
+  public void ignoreChangeBySetStars() throws Exception {
     TestAccount user2 = accountCreator.user2();
     accountIndexedCounter.clear();
 
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 419b78d..7f7fe00 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
@@ -81,6 +81,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeKind;
@@ -122,6 +123,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
@@ -3428,4 +3430,90 @@
     assertThat(trackingIds.iterator().next().system).isEqualTo("JIRA");
     assertThat(trackingIds.iterator().next().id).isEqualTo("JRA001");
   }
+
+  @Test
+  public void ignore() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    in = new AddReviewerInput();
+    in.reviewer = user2.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).ignore(true);
+    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isTrue();
+
+    sender.clear();
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).abandon();
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).ignore(false);
+    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isFalse();
+  }
+
+  @Test
+  public void cannotIgnoreOwnChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("cannot ignore own change");
+    gApi.changes().id(changeId).ignore(true);
+  }
+
+  @Test
+  public void cannotIgnoreStarredChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.accounts().self().starChange(changeId);
+    assertThat(gApi.changes().id(changeId).get().starred).isTrue();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.DEFAULT_LABEL
+            + " and "
+            + StarredChangesUtil.IGNORE_LABEL
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.changes().id(changeId).ignore(true);
+  }
+
+  @Test
+  public void cannotStarIgnoredChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).ignore(true);
+    assertThat(gApi.changes().id(changeId).ignored()).isTrue();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.DEFAULT_LABEL
+            + " and "
+            + StarredChangesUtil.IGNORE_LABEL
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.accounts().self().starChange(changeId);
+  }
+
+  @Test
+  public void cannotSetInvalidLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // label cannot contain whitespace
+    String invalidLabel = "invalid label";
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid labels: " + invalidLabel);
+    gApi.accounts().self().setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel)));
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 9bc09d2..7081245 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -112,6 +112,13 @@
   void ignore(boolean ignore) throws RestApiException;
 
   /**
+   * Check if this change is ignored.
+   *
+   * @return true if the change is ignored
+   */
+  boolean ignored() throws RestApiException;
+
+  /**
    * Mute or un-mute this change.
    *
    * @param mute mute the change if true
@@ -562,6 +569,11 @@
     }
 
     @Override
+    public boolean ignored() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void mute(boolean mute) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index c1f0989..61ab3a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -33,6 +33,7 @@
 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.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -122,26 +123,33 @@
     }
   }
 
-  public static class IllegalLabelException extends IllegalArgumentException {
+  public static class IllegalLabelException extends Exception {
     private static final long serialVersionUID = 1L;
 
-    static IllegalLabelException invalidLabels(Set<String> invalidLabels) {
-      return new IllegalLabelException(
-          String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
-    }
-
-    static IllegalLabelException mutuallyExclusiveLabels(String label1, String label2) {
-      return new IllegalLabelException(
-          String.format(
-              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
-              label1, label2));
-    }
-
     IllegalLabelException(String message) {
       super(message);
     }
   }
 
+  public static class InvalidLabelsException extends IllegalLabelException {
+    private static final long serialVersionUID = 1L;
+
+    InvalidLabelsException(Set<String> invalidLabels) {
+      super(String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
+    }
+  }
+
+  public static class MutuallyExclusiveLabelsException extends IllegalLabelException {
+    private static final long serialVersionUID = 1L;
+
+    MutuallyExclusiveLabelsException(String label1, String label2) {
+      super(
+          String.format(
+              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
+              label1, label2));
+    }
+  }
+
   private static final Logger log = LoggerFactory.getLogger(StarredChangesUtil.class);
 
   public static final String DEFAULT_LABEL = "star";
@@ -192,7 +200,7 @@
       Change.Id changeId,
       Set<String> labelsToAdd,
       Set<String> labelsToRemove)
-      throws OrmException {
+      throws OrmException, IllegalLabelException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
@@ -296,26 +304,38 @@
     }
   }
 
-  public void ignore(Account.Id accountId, Project.NameKey project, Change.Id changeId)
-      throws OrmException {
-    star(accountId, project, changeId, ImmutableSet.of(IGNORE_LABEL), ImmutableSet.of());
+  public void ignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(IGNORE_LABEL),
+        ImmutableSet.of());
   }
 
-  public void unignore(Account.Id accountId, Project.NameKey project, Change.Id changeId)
-      throws OrmException {
-    star(accountId, project, changeId, ImmutableSet.of(), ImmutableSet.of(IGNORE_LABEL));
+  public void unignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(),
+        ImmutableSet.of(IGNORE_LABEL));
   }
 
   public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) throws OrmException {
     return getLabels(accountId, changeId).contains(IGNORE_LABEL);
   }
 
+  public boolean isIgnored(ChangeResource rsrc) throws OrmException {
+    return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
+  }
+
   private static String getMuteLabel(Change change) {
     return MUTE_LABEL + "/" + change.currentPatchSetId().get();
   }
 
   public void mute(Account.Id accountId, Project.NameKey project, Change change)
-      throws OrmException {
+      throws OrmException, IllegalLabelException {
     star(
         accountId,
         project,
@@ -325,7 +345,7 @@
   }
 
   public void unmute(Account.Id accountId, Project.NameKey project, Change change)
-      throws OrmException {
+      throws OrmException, IllegalLabelException {
     star(
         accountId,
         project,
@@ -355,7 +375,7 @@
   }
 
   public static ObjectId writeLabels(Repository repo, Collection<String> labels)
-      throws IOException {
+      throws IOException, InvalidLabelsException {
     validateLabels(labels);
     try (ObjectInserter oi = repo.newObjectInserter()) {
       ObjectId id =
@@ -367,13 +387,14 @@
     }
   }
 
-  private static void checkMutuallyExclusiveLabels(Set<String> labels) {
+  private static void checkMutuallyExclusiveLabels(Set<String> labels)
+      throws MutuallyExclusiveLabelsException {
     if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
-      throw IllegalLabelException.mutuallyExclusiveLabels(DEFAULT_LABEL, IGNORE_LABEL);
+      throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
     }
   }
 
-  private static void validateLabels(Collection<String> labels) {
+  private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
     if (labels == null) {
       return;
     }
@@ -385,13 +406,13 @@
       }
     }
     if (!invalidLabels.isEmpty()) {
-      throw IllegalLabelException.invalidLabels(invalidLabels);
+      throw new InvalidLabelsException(invalidLabels);
     }
   }
 
   private void updateLabels(
       Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
-      throws IOException, OrmException {
+      throws IOException, OrmException, InvalidLabelsException {
     try (RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
index e3c4bb1..ad73a69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
@@ -20,8 +20,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -30,6 +32,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -129,7 +133,7 @@
 
     @Override
     public Response<?> apply(AccountResource rsrc, EmptyInput in)
-        throws AuthException, OrmException, IOException {
+        throws RestApiException, OrmException, IOException {
       if (self.get() != rsrc.getUser()) {
         throw new AuthException("not allowed to add starred change");
       }
@@ -140,6 +144,10 @@
             change.getId(),
             StarredChangesUtil.DEFAULT_LABELS,
             null);
+      } catch (MutuallyExclusiveLabelsException e) {
+        throw new ResourceConflictException(e.getMessage());
+      } catch (IllegalLabelException e) {
+        throw new BadRequestException(e.getMessage());
       } catch (OrmDuplicateKeyException e) {
         return Response.none();
       }
@@ -179,7 +187,7 @@
 
     @Override
     public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException, OrmException, IOException {
+        throws AuthException, OrmException, IOException, IllegalLabelException {
       if (self.get() != rsrc.getUser()) {
         throw new AuthException("not allowed remove starred change");
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 2dc9f6c..d006c7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -48,6 +48,8 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.Abandon;
 import com.google.gerrit.server.change.ChangeIncludedIn;
 import com.google.gerrit.server.change.ChangeJson;
@@ -89,6 +91,7 @@
 import com.google.gerrit.server.change.Unignore;
 import com.google.gerrit.server.change.Unmute;
 import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -145,6 +148,7 @@
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
   private final GetPureRevert getPureRevert;
+  private final StarredChangesUtil stars;
 
   @Inject
   ChangeApiImpl(
@@ -190,6 +194,7 @@
       SetReadyForReview setReady,
       PutMessage putMessage,
       GetPureRevert getPureRevert,
+      StarredChangesUtil stars,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
@@ -233,6 +238,7 @@
     this.setReady = setReady;
     this.putMessage = putMessage;
     this.getPureRevert = getPureRevert;
+    this.stars = stars;
     this.change = change;
   }
 
@@ -657,10 +663,23 @@
   public void ignore(boolean ignore) throws RestApiException {
     // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
     // StarredChangesUtil.
-    if (ignore) {
-      this.ignore.apply(change, new Ignore.Input());
-    } else {
-      unignore.apply(change, new Unignore.Input());
+    try {
+      if (ignore) {
+        this.ignore.apply(change, new Ignore.Input());
+      } else {
+        unignore.apply(change, new Unignore.Input());
+      }
+    } catch (OrmException | IllegalLabelException e) {
+      throw asRestApiException("Cannot ignore change", e);
+    }
+  }
+
+  @Override
+  public boolean ignored() throws RestApiException {
+    try {
+      return stars.isIgnoredBy(change.getId(), change.getUser().getAccountId());
+    } catch (OrmException e) {
+      throw asRestApiException("Cannot check if ignored", e);
     }
   }
 
@@ -668,10 +687,14 @@
   public void mute(boolean mute) throws RestApiException {
     // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
     // StarredChangesUtil.
-    if (mute) {
-      this.mute.apply(change, new Mute.Input());
-    } else {
-      unmute.apply(change, new Unmute.Input());
+    try {
+      if (mute) {
+        this.mute.apply(change, new Mute.Input());
+      } else {
+        unmute.apply(change, new Unmute.Input());
+      }
+    } catch (OrmException | IllegalLabelException e) {
+      throw asRestApiException("Cannot mute change", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
index a955abe..fb2fb27 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
@@ -59,14 +59,7 @@
     return new ListRequest() {
       @Override
       public SortedMap<String, PluginInfo> getAsMap() throws RestApiException {
-        ListPlugins list = listProvider.get();
-        list.setAll(this.getAll());
-        list.setStart(this.getStart());
-        list.setLimit(this.getLimit());
-        list.setMatchPrefix(this.getPrefix());
-        list.setMatchSubstring(this.getSubstring());
-        list.setMatchRegex(this.getRegex());
-        return list.apply(TopLevelResource.INSTANCE);
+        return listProvider.get().request(this).apply(TopLevelResource.INSTANCE);
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index a206a49..60dc474 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.server.project.PutDescription;
 import com.google.gerrit.server.project.SetAccess;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
@@ -92,8 +93,8 @@
   private final CreateAccessChange createAccessChange;
   private final GetConfig getConfig;
   private final PutConfig putConfig;
-  private final ListBranches listBranches;
-  private final ListTags listTags;
+  private final Provider<ListBranches> listBranches;
+  private final Provider<ListTags> listTags;
   private final DeleteBranches deleteBranches;
   private final DeleteTags deleteTags;
   private final CommitsCollection commitsCollection;
@@ -119,8 +120,8 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
-      ListBranches listBranches,
-      ListTags listTags,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
       CommitsCollection commitsCollection,
@@ -175,8 +176,8 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
-      ListBranches listBranches,
-      ListTags listTags,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
       CommitsCollection commitsCollection,
@@ -230,8 +231,8 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
-      ListBranches listBranches,
-      ListTags listTags,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
       ProjectResource project,
@@ -354,46 +355,29 @@
     return new ListRefsRequest<BranchInfo>() {
       @Override
       public List<BranchInfo> get() throws RestApiException {
-        return listBranches(this);
+        try {
+          return listBranches.get().request(this).apply(checkExists());
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list branches", e);
+        }
       }
     };
   }
 
-  private List<BranchInfo> listBranches(ListRefsRequest<BranchInfo> request)
-      throws RestApiException {
-    listBranches.setLimit(request.getLimit());
-    listBranches.setStart(request.getStart());
-    listBranches.setMatchSubstring(request.getSubstring());
-    listBranches.setMatchRegex(request.getRegex());
-    try {
-      return listBranches.apply(checkExists());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list branches", e);
-    }
-  }
-
   @Override
   public ListRefsRequest<TagInfo> tags() {
     return new ListRefsRequest<TagInfo>() {
       @Override
       public List<TagInfo> get() throws RestApiException {
-        return listTags(this);
+        try {
+          return listTags.get().request(this).apply(checkExists());
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list tags", e);
+        }
       }
     };
   }
 
-  private List<TagInfo> listTags(ListRefsRequest<TagInfo> request) throws RestApiException {
-    listTags.setLimit(request.getLimit());
-    listTags.setStart(request.getStart());
-    listTags.setMatchSubstring(request.getSubstring());
-    listTags.setMatchRegex(request.getRegex());
-    try {
-      return listTags.apply(checkExists());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list tags", e);
-    }
-  }
-
   @Override
   public List<ProjectInfo> children() throws RestApiException {
     return children(false);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
index 83ab811..46dabdf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
@@ -14,15 +14,17 @@
 
 package com.google.gerrit.server.change;
 
+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.extensions.webui.UiAction;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -34,12 +36,10 @@
 
   public static class Input {}
 
-  private final Provider<IdentifiedUser> self;
   private final StarredChangesUtil stars;
 
   @Inject
-  Ignore(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
-    this.self = self;
+  Ignore(StarredChangesUtil stars) {
     this.stars = stars;
   }
 
@@ -48,26 +48,33 @@
     return new UiAction.Description()
         .setLabel("Ignore")
         .setTitle("Ignore the change")
-        .setVisible(!rsrc.isUserOwner() && !isIgnored(rsrc));
+        .setVisible(canIgnore(rsrc));
   }
 
   @Override
-  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, OrmException, IllegalLabelException {
     try {
-      if (rsrc.isUserOwner() || isIgnored(rsrc)) {
-        // early exit for own changes and already ignored changes
-        return Response.ok("");
+      if (rsrc.isUserOwner()) {
+        throw new BadRequestException("cannot ignore own change");
       }
-      stars.ignore(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange().getId());
-    } catch (OrmException e) {
-      throw new RestApiException("failed to ignore change", e);
+
+      if (!isIgnored(rsrc)) {
+        stars.ignore(rsrc);
+      }
+      return Response.ok("");
+    } catch (MutuallyExclusiveLabelsException e) {
+      throw new ResourceConflictException(e.getMessage());
     }
-    return Response.ok("");
+  }
+
+  private boolean canIgnore(ChangeResource rsrc) {
+    return !rsrc.isUserOwner() && !isIgnored(rsrc);
   }
 
   private boolean isIgnored(ChangeResource rsrc) {
     try {
-      return stars.isIgnoredBy(rsrc.getChange().getId(), self.get().getAccountId());
+      return stars.isIgnored(rsrc);
     } catch (OrmException e) {
       log.error("failed to check ignored star", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java
index d14fec8..5fb3ef6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -52,16 +53,13 @@
   }
 
   @Override
-  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
-    try {
-      if (rsrc.isUserOwner() || isMuted(rsrc.getChange())) {
-        // early exit for own changes and already muted changes
-        return Response.ok("");
-      }
-      stars.mute(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange());
-    } catch (OrmException e) {
-      throw new RestApiException("failed to mute change", e);
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, OrmException, IllegalLabelException {
+    if (rsrc.isUserOwner() || isMuted(rsrc.getChange())) {
+      // early exit for own changes and already muted changes
+      return Response.ok("");
     }
+    stars.mute(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange());
     return Response.ok("");
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
index 081fc22..a10e754 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
@@ -18,11 +18,10 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -34,12 +33,10 @@
 
   public static class Input {}
 
-  private final Provider<IdentifiedUser> self;
   private final StarredChangesUtil stars;
 
   @Inject
-  Unignore(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
-    this.self = self;
+  Unignore(StarredChangesUtil stars) {
     this.stars = stars;
   }
 
@@ -48,26 +45,21 @@
     return new UiAction.Description()
         .setLabel("Unignore")
         .setTitle("Unignore the change")
-        .setVisible(!rsrc.isUserOwner() && isIgnored(rsrc));
+        .setVisible(isIgnored(rsrc));
   }
 
   @Override
-  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
-    try {
-      if (rsrc.isUserOwner() || !isIgnored(rsrc)) {
-        // early exit for own changes and not ignored changes
-        return Response.ok("");
-      }
-      stars.unignore(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange().getId());
-    } catch (OrmException e) {
-      throw new RestApiException("failed to unignore change", e);
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, OrmException, IllegalLabelException {
+    if (isIgnored(rsrc)) {
+      stars.unignore(rsrc);
     }
     return Response.ok("");
   }
 
   private boolean isIgnored(ChangeResource rsrc) {
     try {
-      return stars.isIgnoredBy(rsrc.getChange().getId(), self.get().getAccountId());
+      return stars.isIgnored(rsrc);
     } catch (OrmException e) {
       log.error("failed to check ignored star", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java
index 49b41cb..586f3e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -53,16 +54,13 @@
   }
 
   @Override
-  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
-    try {
-      if (rsrc.isUserOwner() || !isMuted(rsrc.getChange())) {
-        // early exit for own changes and not muted changes
-        return Response.ok("");
-      }
-      stars.unmute(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange());
-    } catch (OrmException e) {
-      throw new RestApiException("failed to unmute change", e);
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, OrmException, IllegalLabelException {
+    if (rsrc.isUserOwner() || !isMuted(rsrc.getChange())) {
+      // early exit for own changes and not muted changes
+      return Response.ok("");
     }
+    stars.unmute(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange());
     return Response.ok("");
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
index 97d728d..9a42bb9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.plugins.Plugins;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -104,6 +105,16 @@
     this.pluginLoader = pluginLoader;
   }
 
+  public ListPlugins request(Plugins.ListRequest request) {
+    this.setAll(request.getAll());
+    this.setStart(request.getStart());
+    this.setLimit(request.getLimit());
+    this.setMatchPrefix(request.getPrefix());
+    this.setMatchSubstring(request.getSubstring());
+    this.setMatchRegex(request.getRegex());
+    return this;
+  }
+
   @Override
   public SortedMap<String, PluginInfo> apply(TopLevelResource resource) throws BadRequestException {
     Stream<Plugin> s = Streams.stream(pluginLoader.getPlugins(all));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
index b7af949..b2edc6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -119,6 +120,14 @@
     this.webLinks = webLinks;
   }
 
+  public ListBranches request(ListRefsRequest<BranchInfo> request) {
+    this.setLimit(request.getLimit());
+    this.setStart(request.getStart());
+    this.setMatchSubstring(request.getSubstring());
+    this.setMatchRegex(request.getRegex());
+    return this;
+  }
+
   @Override
   public List<BranchInfo> apply(ProjectResource rsrc)
       throws ResourceNotFoundException, IOException, BadRequestException,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
index 302f8e6..a58f316 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -114,6 +115,14 @@
     this.links = webLinks;
   }
 
+  public ListTags request(ListRefsRequest<TagInfo> request) {
+    this.setLimit(request.getLimit());
+    this.setStart(request.getStart());
+    this.setMatchSubstring(request.getSubstring());
+    this.setMatchRegex(request.getRegex());
+    return this;
+  }
+
   @Override
   public List<TagInfo> apply(ProjectResource resource)
       throws IOException, ResourceNotFoundException, BadRequestException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
index ec63141..31cfd5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.jdbc.JdbcSchema;
@@ -78,7 +79,7 @@
                 ObjectId.zeroId(), id, RefNames.refsStarredChanges(e.getValue(), e.getKey())));
       }
       bru.execute(rw, new TextProgressMonitor());
-    } catch (IOException ex) {
+    } catch (IOException | IllegalLabelException ex) {
       throw new OrmException(ex);
     }
   }
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index b6f8d99..63761f2 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -2,7 +2,7 @@
 
 _JGIT_VERS = "4.8.0.201706111038-r.71-g45da0fc6f"
 
-_DOC_VERS = _JGIT_VERS # Set to _JGIT_VERS unless using a snapshot
+_DOC_VERS = "4.8.0.201706111038-r" # Set to _JGIT_VERS unless using a snapshot
 
 JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index 5110a28..9936730 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -46,6 +46,17 @@
     patchNumEquals(a, b) {
       return a + '' === b + '';
     },
+
+    /**
+     * Whether the given patch is a numbered parent of a merge (i.e. a negative
+     * number).
+     * @param  {string|number} n
+     * @return {Boolean}
+     */
+    isMergeParent(n) {
+      return (n + '')[0] === '-';
+    },
+
     /**
      * Given an object of revisions, get a particular revision based on patch
      * num.
@@ -192,6 +203,13 @@
       return allPatchSets[allPatchSets.length - 1].num;
     },
 
+    /** @return {Boolean} */
+    hasEditBasedOnCurrentPatchSet(allPatchSets) {
+      if (!allPatchSets || !allPatchSets.length) { return false; }
+      return allPatchSets[allPatchSets.length - 1].num ===
+          Gerrit.PatchSetBehavior.EDIT_NAME;
+    },
+
     /**
      * Check whether there is no newer patch than the latest patch that was
      * available when this change was loaded.
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index c2ccccd..54c1355 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -174,6 +174,20 @@
       assert.isTrue(equals('PARENT', 'PARENT'));
     });
 
+    test('isMergeParent', () => {
+      const isParent = Gerrit.PatchSetBehavior.isMergeParent;
+      assert.isFalse(isParent(1));
+      assert.isFalse(isParent(4321));
+      assert.isFalse(isParent('52'));
+      assert.isFalse(isParent('edit'));
+      assert.isFalse(isParent('PARENT'));
+      assert.isFalse(isParent(0));
+
+      assert.isTrue(isParent(-23));
+      assert.isTrue(isParent(-1));
+      assert.isTrue(isParent('-42'));
+    });
+
     test('findEditParentRevision', () => {
       const findParent = Gerrit.PatchSetBehavior.findEditParentRevision;
       let revisions = [
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index f2ed33a..b7a048b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -157,6 +157,19 @@
           Do you really want to delete the change?
         </div>
       </gr-confirm-dialog>
+      <gr-confirm-dialog
+          id="confirmDeleteEditDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          on-cancel="_handleConfirmDialogCancel"
+          on-confirm="_handleDeleteEditConfirm">
+        <div class="header">
+          Delete Change Edit
+        </div>
+        <div class="main">
+          Do you really want to delete the edit?
+        </div>
+      </gr-confirm-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index ff77142..5657cbe 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -51,11 +51,14 @@
   const ChangeActions = {
     ABANDON: 'abandon',
     DELETE: '/',
+    DELETE_EDIT: 'deleteEdit',
     IGNORE: 'ignore',
     MOVE: 'move',
     MUTE: 'mute',
     PRIVATE: 'private',
     PRIVATE_DELETE: 'private.delete',
+    PUBLISH_EDIT: 'publishEdit',
+    REBASE_EDIT: 'rebaseEdit',
     RESTORE: 'restore',
     REVERT: 'revert',
     UNIGNORE: 'unignore',
@@ -118,6 +121,36 @@
     __type: 'revision',
   };
 
+  const REBASE_EDIT = {
+    enabled: true,
+    label: 'Rebase Edit',
+    title: 'Rebase change edit',
+    __key: 'rebaseEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'POST',
+  };
+
+  const PUBLISH_EDIT = {
+    enabled: true,
+    label: 'Publish Edit',
+    title: 'Publish change edit',
+    __key: 'publishEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'POST',
+  };
+
+  const DELETE_EDIT = {
+    enabled: true,
+    label: 'Delete Edit',
+    title: 'Delete change edit',
+    __key: 'deleteEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'DELETE',
+  };
+
   const AWAIT_CHANGE_ATTEMPTS = 5;
   const AWAIT_CHANGE_TIMEOUT_MS = 1000;
 
@@ -276,6 +309,14 @@
         type: Array,
         value() { return []; },
       },
+      editLoaded: {
+        type: Boolean,
+        value: false,
+      },
+      editBasedOnCurrentPatchSet: {
+        type: Boolean,
+        value: true,
+      },
     },
 
     ActionType,
@@ -288,7 +329,8 @@
     ],
 
     observers: [
-      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
+      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*, ' +
+          'editLoaded, editBasedOnCurrentPatchSet, change)',
     ],
 
     listeners: {
@@ -422,7 +464,8 @@
     },
 
     _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
-        additionalActionsChangeRecord) {
+        additionalActionsChangeRecord, editLoaded, editBasedOnCurrentPatchSet,
+        change) {
       const additionalActions = (additionalActionsChangeRecord &&
           additionalActionsChangeRecord.base) || [];
       this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
@@ -436,6 +479,47 @@
           !revisionActions.download) {
         this.set('revisionActions.download', DOWNLOAD_ACTION);
       }
+
+      const changeActions = actionsChangeRecord.base || {};
+      if (Object.keys(changeActions).length !== 0) {
+        if (editLoaded) {
+          if (this.changeIsOpen(change.status)) {
+            if (editBasedOnCurrentPatchSet) {
+              if (!changeActions.publishEdit) {
+                this.set('actions.publishEdit', PUBLISH_EDIT);
+              }
+              if (changeActions.rebaseEdit) {
+                delete this.actions.rebaseEdit;
+                this.notifyPath('actions.rebaseEdit');
+              }
+            } else {
+              if (!changeActions.rebasEdit) {
+                this.set('actions.rebaseEdit', REBASE_EDIT);
+              }
+              if (changeActions.publishEdit) {
+                delete this.actions.publishEdit;
+                this.notifyPath('actions.publishEdit');
+              }
+            }
+          }
+          if (!changeActions.deleteEdit) {
+            this.set('actions.deleteEdit', DELETE_EDIT);
+          }
+        } else {
+          if (changeActions.publishEdit) {
+            delete this.actions.publishEdit;
+            this.notifyPath('actions.publishEdit');
+          }
+          if (changeActions.rebaseEdit) {
+            delete this.actions.rebaseEdit;
+            this.notifyPath('actions.rebaseEdit');
+          }
+          if (changeActions.deleteEdit) {
+            delete this.actions.deleteEdit;
+            this.notifyPath('actions.deleteEdit');
+          }
+        }
+      }
     },
 
     _getValuesFor(obj) {
@@ -638,12 +722,21 @@
         case ChangeActions.DELETE:
           this._handleDeleteTap();
           break;
+        case ChangeActions.DELETE_EDIT:
+          this._handleDeleteEditTap();
+          break;
         case ChangeActions.WIP:
           this._handleWipTap();
           break;
         case ChangeActions.MOVE:
           this._handleMoveTap();
           break;
+        case ChangeActions.PUBLISH_EDIT:
+          this._handlePublishEditTap();
+          break;
+        case ChangeActions.REBASE_EDIT:
+          this._handleRebaseEditTap();
+          break;
         default:
           this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
@@ -775,6 +868,12 @@
       this._fireAction('/', this.actions[ChangeActions.DELETE], false);
     },
 
+    _handleDeleteEditConfirm() {
+      this._hideAllDialogs();
+
+      this._fireAction('/edit', this.actions.deleteEdit, false);
+    },
+
     _getActionOverflowIndex(type, key) {
       return this._overflowActions.findIndex(action => {
         return action.type === type && action.key === key;
@@ -862,6 +961,9 @@
             }
             break;
           case ChangeActions.WIP:
+          case ChangeActions.DELETE_EDIT:
+          case ChangeActions.PUBLISH_EDIT:
+          case ChangeActions.REBASE_EDIT:
             page.show(this.changePath(this.changeNum));
             break;
           default:
@@ -949,10 +1051,22 @@
       this._showActionDialog(this.$.confirmDeleteDialog);
     },
 
+    _handleDeleteEditTap() {
+      this._showActionDialog(this.$.confirmDeleteEditDialog);
+    },
+
     _handleWipTap() {
       this._fireAction('/wip', this.actions.wip, false);
     },
 
+    _handlePublishEditTap() {
+      this._fireAction('/edit:publish', this.actions.publishEdit, false);
+    },
+
+    _handleRebaseEditTap() {
+      this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
+    },
+
     _handleHideBackgroundContent() {
       this.$.mainContent.classList.add('overlayOpen');
     },
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 11a2569..2c252eb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -360,6 +360,137 @@
       assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
     });
 
+    suite('change edits', () => {
+      let fireActionStub;
+      const deleteEditAction = {
+        enabled: true,
+        label: 'Delete Edit',
+        title: 'Delete change edit',
+        __key: 'deleteEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'DELETE',
+      };
+      const publishEditAction = {
+        enabled: true,
+        label: 'Publish Edit',
+        title: 'Publish change edit',
+        __key: 'publishEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'POST',
+      };
+      const rebaseEditAction = {
+        enabled: true,
+        label: 'Rebase Edit',
+        title: 'Rebase change edit',
+        __key: 'rebaseEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'POST',
+      };
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        element.patchNum = 'edit';
+        element.editLoaded = true;
+      });
+
+      test('does not delete edit on action', () => {
+        element._handleDeleteEditTap();
+        assert.isFalse(fireActionStub.called);
+      });
+
+      test('shows confirm dialog for delete edit', () => {
+        element._handleDeleteEditTap();
+        assert.isFalse(element.$$('#confirmDeleteEditDialog').hidden);
+        assert.ok(element.$$('gr-button[data-action-key="deleteEdit"]'));
+        MockInteractions.tap(
+            element.$$('#confirmDeleteEditDialog').$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+        assert.isTrue(
+            fireActionStub.calledWith('/edit', deleteEditAction, false));
+      });
+
+      test('show publish edit but rebaseEdit is hidden', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.isNotOk(rebaseEditButton);
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.ok(publishEditButton);
+        MockInteractions.tap(publishEditButton);
+        element._handlePublishEditTap();
+        flushAsynchronousOperations();
+
+        assert.isTrue(
+            fireActionStub.calledWith('/edit:publish', publishEditAction, false));
+      });
+
+      test('show rebase edit but publishEdit is hidden', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        element.editBasedOnCurrentPatchSet = false;
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.ok(rebaseEditButton);
+        MockInteractions.tap(rebaseEditButton);
+        element._handleRebaseEditTap();
+        flushAsynchronousOperations();
+
+        assert.isTrue(
+            fireActionStub.calledWith('/edit:rebase', rebaseEditAction, false));
+      });
+
+      test('hide publishEdit and rebaseEdit if change is not open', () => {
+        element.change = {
+          status: 'MERGED',
+        };
+        flushAsynchronousOperations();
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.isNotOk(rebaseEditButton);
+
+        const deleteEditButton =
+            element.$$('gr-button[data-action-key="deleteEdit"]');
+        assert.ok(deleteEditButton);
+      });
+
+      test('do not show delete edit on a non change edit', () => {
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+        const deleteEditButton =
+            element.$$('gr-button[data-action-key="deleteEdit"]');
+        assert.isNotOk(deleteEditButton);
+      });
+
+      test('do not show publish edit on a non change edit', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+      });
+    });
+
     suite('cherry-pick', () => {
       let fireActionStub;
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index dbba7ad..031acda 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -351,6 +351,8 @@
                 commit-num="[[_commitInfo.commit]]"
                 patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
                 commit-message="[[_latestCommitMessage]]"
+                edit-loaded="[[_editLoaded]]"
+                edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
                 on-reload-change="_handleReloadChange"
                 on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
           </div>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index b4ae93f..5c2422d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -44,13 +44,15 @@
         display: initial;
       }
       .patchInfo-header {
-        padding: .5em calc(var(--default-horizontal-margin) / 2);
-      }
-      .patchInfo-header {
         background-color: #f6f6f6;
         border-bottom: 1px solid #ebebeb;
         display: flex;
-        justify-content: space-between;
+        padding: .5em calc(var(--default-horizontal-margin) / 2);
+      }
+      .patchInfo-header-wrapper {
+        align-items: center;
+        display: flex;
+        width: 100%;
       }
       .latestPatchContainer {
         display: none;
@@ -63,11 +65,9 @@
       }
       #diffPrefsContainer,
       .rightControls {
+        align-self: flex-end;
         margin: auto 0 auto auto;
       }
-      .patchInfo-header-wrapper {
-        width: 100%;
-      }
       .showOnEdit {
         display: none;
       }
@@ -78,12 +78,13 @@
         display: initial;
       }
       .fileList-header {
+        align-items: center;
         display: flex;
         font-weight: bold;
-        justify-content: space-between;
-        margin: .5em calc(var(--default-horizontal-margin) / 2);
+        margin: .5em calc(var(--default-horizontal-margin) / 2) 0;
       }
       .rightControls {
+        align-items: center;
         display: flex;
         flex-wrap: wrap;
         font-weight: normal;
@@ -109,40 +110,42 @@
     </style>
     <div class$="patchInfo-header [[_computeEditLoadedClass(editLoaded)]] [[_computePatchInfoClass(patchRange.patchNum, allPatchSets)]]">
       <div class="patchInfo-header-wrapper">
-        <gr-patch-range-select
-            id="rangeSelect"
-            comments="[[comments]]"
-            change-num="[[changeNum]]"
-            patch-range="[[patchRange]]"
-            available-patches="[[allPatchSets]]"
-            revisions="[[change.revisions]]"
-            on-patch-range-change="_handlePatchChange">
-        </gr-patch-range-select>
-        /
-        <gr-commit-info
-            change="[[change]]"
-            server-config="[[serverConfig]]"
-            commit-info="[[commitInfo]]"></gr-commit-info>
-        <span class="latestPatchContainer">
+        <div>
+          <gr-patch-range-select
+              id="rangeSelect"
+              comments="[[comments]]"
+              change-num="[[changeNum]]"
+              patch-range="[[patchRange]]"
+              available-patches="[[allPatchSets]]"
+              revisions="[[change.revisions]]"
+              on-patch-range-change="_handlePatchChange">
+          </gr-patch-range-select>
           /
-          <a href$="[[changeUrl]]">Go to latest patch set</a>
-        </span>
-        <span class="downloadContainer desktop">
-          /
-          <gr-button link
-              class="download"
-              on-tap="_handleDownloadTap">Download</gr-button>
-        </span>
-        <span class="descriptionContainer hideOnEdit">
-          /
-          <gr-editable-label
-              id="descriptionLabel"
-              class="descriptionLabel"
-              value="[[_computePatchSetDescription(change, patchRange.patchNum)]]"
-              placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-              read-only="[[_descriptionReadOnly]]"
-              on-changed="_handleDescriptionChanged"></gr-editable-label>
-        </span>
+          <gr-commit-info
+              change="[[change]]"
+              server-config="[[serverConfig]]"
+              commit-info="[[commitInfo]]"></gr-commit-info>
+          <span class="latestPatchContainer">
+            /
+            <a href$="[[changeUrl]]">Go to latest patch set</a>
+          </span>
+          <span class="downloadContainer desktop">
+            /
+            <gr-button link
+                class="download"
+                on-tap="_handleDownloadTap">Download</gr-button>
+          </span>
+          <span class="descriptionContainer hideOnEdit">
+            /
+            <gr-editable-label
+                id="descriptionLabel"
+                class="descriptionLabel"
+                value="[[_computePatchSetDescription(change, patchRange.patchNum)]]"
+                placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+                read-only="[[_descriptionReadOnly]]"
+                on-changed="_handleDescriptionChanged"></gr-editable-label>
+          </span>
+        </div>
         <span id="diffPrefsContainer"
             class="hideOnEdit"
             hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 0a2b974..ac74ee5 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -126,7 +126,9 @@
      */
     _showAlert(text, opt_actionText, opt_actionCallback,
         opt_dismissOnNavigation) {
-      if (this._alertElement) { return; }
+      if (this._alertElement) {
+        this._hideAlert();
+      }
 
       this._clearHideAlertHandle();
       if (opt_dismissOnNavigation) {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 5d1c461..17fa746 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -272,20 +272,11 @@
       });
     });
 
-    test('dismissOnNavigation respected', () => {
-      const asyncStub = sandbox.stub(element, 'async');
-      const hideSpy = sandbox.spy(element, '_hideAlert');
-      // No async call when dismissOnNavigation supplied.
-      element._showAlert('test', null, null, true);
-      assert.isFalse(asyncStub.called);
-
-      // When page nav happens, clear alert.
-      document.dispatchEvent(new CustomEvent('location-change'));
-      assert.isTrue(hideSpy.called);
-
-      // When timeout is not supplied, use HIDE_ALERT_TIMEOUT_MS.
-      element._showAlert('test');
-      assert.isTrue(asyncStub.called);
+    test('_showAlert hides existing alerts', () => {
+      element._alertElement = element._createToastAlert();
+      const hideStub = sandbox.stub(element, '_hideAlert');
+      element._showAlert();
+      assert.isTrue(hideStub.calledOnce);
     });
   });
 </script>
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 a2861c6..acc9d4f 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -91,7 +91,7 @@
     QUERY_OFFSET: '/q/:query,:offset',
 
     // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
-    CHANGE_LEGACY: /^\/c\/(\d+)\/?(((\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+    CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
     CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
 
     // Matches
@@ -100,15 +100,15 @@
     // TODO(kaspern): Migrate completely to project based URLs, with backwards
     // compatibility for change-only.
     // eslint-disable-next-line max-len
-    CHANGE_OR_DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/?((\d+|edit)(\.\.(\d+|edit))?(\/(.+))?))?\/?$/,
+    CHANGE_OR_DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))?))?\/?$/,
 
     // Matches
     //     /c/<project>/+/<changeNum>/[<basePatchNum>..]<patchNum>/<path>,edit
     // eslint-disable-next-line max-len
-    DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(((\d+|edit)\.\.)?(edit)(\/(.+)))),edit$/,
+    DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(((-?\d+|edit)\.\.)?(edit)(\/(.+)))),edit$/,
 
     // Matches /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-    DIFF_LEGACY: /^\/c\/(\d+)\/((\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
+    DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
 
     SETTINGS: /^\/settings\/?/,
     SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
@@ -298,8 +298,17 @@
       const hasPatchNum = params.patchNum !== null &&
           params.patchNum !== undefined;
       let needsRedirect = false;
+
+      // Diffing a patch against itself is invalid, so if the base and revision
+      // patches are equal clear the base.
+      // NOTE: while selecting numbered parents of a merge is not yet
+      // implemented, normalize parent base patches to be un-selected parents in
+      // the same way.
+      // TODO(issue 4760): Remove the isMergeParent check when PG supports
+      // diffing against numbered parents of a merge.
       if (hasBasePatchNum &&
-          this.patchNumEquals(params.basePatchNum, params.patchNum)) {
+          (this.patchNumEquals(params.basePatchNum, params.patchNum) ||
+              this.isMergeParent(params.basePatchNum))) {
         needsRedirect = true;
         params.basePatchNum = null;
       } else if (hasBasePatchNum && !hasPatchNum) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index df46a38..a9231b6 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -376,6 +376,16 @@
           assert.isNotOk(params.basePatchNum);
           assert.equal(params.patchNum, 'edit');
         });
+
+        // TODO(issue 4760): Remove when PG supports diffing against numbered
+        // parents of a merge.
+        test('range -n..m normalizes to m', () => {
+          const params = {basePatchNum: -2, patchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 4);
+        });
       });
     });
 
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 4f0ebcc..bda9a35 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
@@ -15,6 +15,12 @@
   'use strict';
 
   const STORAGE_DEBOUNCE_INTERVAL = 400;
+  const TOAST_DEBOUNCE_INTERVAL = 200;
+
+  const SAVING_MESSAGE = 'Saving';
+  const DRAFT_SINGULAR = 'draft...';
+  const DRAFT_PLURAL = 'drafts...';
+  const SAVED_MESSAGE = 'All changes saved';
 
   Polymer({
     is: 'gr-diff-comment',
@@ -109,6 +115,11 @@
         type: Boolean,
         observer: '_toggleResolved',
       },
+
+      _numPendingDiffRequests: {
+        type: Object,
+        value: {number: 0}, // Intentional to share the object across instances.
+      },
     },
 
     observers: [
@@ -424,13 +435,52 @@
       });
     },
 
+    _getSavingMessage(numPending) {
+      if (numPending === 0) { return SAVED_MESSAGE; }
+      return [
+        SAVING_MESSAGE,
+        numPending,
+        numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
+      ].join(' ');
+    },
+
+    _showStartRequest() {
+      const numPending = ++this._numPendingDiffRequests.number;
+      this._updateRequestToast(numPending);
+    },
+
+    _showEndRequest() {
+      const numPending = --this._numPendingDiffRequests.number;
+      this._updateRequestToast(numPending);
+    },
+
+    _updateRequestToast(numPending) {
+      const message = this._getSavingMessage(numPending);
+      this.debounce('draft-toast', () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        document.body.dispatchEvent(new CustomEvent('show-alert',
+            {detail: {message}, bubbles: true}));
+      }, TOAST_DEBOUNCE_INTERVAL);
+    },
+
     _saveDraft(draft) {
-      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft);
+      this._showStartRequest();
+      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
+          .then(result => {
+            this._showEndRequest();
+            return result;
+          });
     },
 
     _deleteDraft(draft) {
+      this._showStartRequest();
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
-          draft);
+          draft).then(result => {
+            this._showEndRequest();
+            return result;
+          });
     },
 
     _getPatchNum() {
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 bdbf4f9..2816647 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
@@ -602,5 +602,34 @@
       element.comment = {unresolved: true};
       assert.isFalse(element.$$('.resolve input').checked);
     });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
+      });
+
+      test('_show{Start,End}Request', () => {
+        const updateStub = sandbox.stub(element, '_updateRequestToast');
+        element._numPendingDiffRequests.number = 1;
+
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDiffRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDiffRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDiffRequests.number, 0);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 5c56826..c3332b3 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -29,6 +29,8 @@
   const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
       'Documentation';
   const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+  const ABSOLUTE_URL_PATTERN = /^https?:/;
+  const TRAILING_SLASH_PATTERN = /\/$/;
 
   Polymer({
     is: 'gr-settings-view',
@@ -366,9 +368,13 @@
 
     _getFilterDocsLink(docsBaseUrl) {
       let base = docsBaseUrl;
-      if (!base || !base.startsWith('http')) {
+      if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
         base = GERRIT_DOCS_BASE_URL;
       }
+
+      // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+      base = base.replace(TRAILING_SLASH_PATTERN, '');
+
       return base + GERRIT_DOCS_FILTER_PATH;
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index a8585da..e1182c1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -386,6 +386,39 @@
       assert.isTrue(element.$.emailEditor.loadData.calledOnce);
     });
 
+    suite('_getFilterDocsLink', () => {
+      test('with http: docs base URL', () => {
+        const base = 'http://example.com/';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'http://example.com/user-notify.html');
+      });
+
+      test('with http: docs base URL without slash', () => {
+        const base = 'http://example.com';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'http://example.com/user-notify.html');
+      });
+
+      test('with https: docs base URL', () => {
+        const base = 'https://example.com/';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'https://example.com/user-notify.html');
+      });
+
+      test('without docs base URL', () => {
+        const result = element._getFilterDocsLink(null);
+        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+            'Documentation/user-notify.html');
+      });
+
+      test('ignores non HTTP links', () => {
+        const base = 'javascript://alert("evil");';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+            'Documentation/user-notify.html');
+      });
+    });
+
     suite('when email verification token is provided', () => {
       let resolveConfirm;