Merge branch 'stable-3.0'

* stable-3.0:
  ChangeIndexer: Stop using deprecated Futures.immediateCheckedFuture
  OutgoingEmail: Use UrlFormatter to get settings URL
  CommentSender: Use UrlFormatter to get URLs for file and comments
  Elasticsearch: Base the default number of shards on ES version
  Disallow change index task duplication
  AbstractPushForReview: Add tests for pushing with skip-validation option
  Set version to 2.16.10-SNAPSHOT
  Set version to 2.16.9
  Documentation: Fix the Elasticsearch shards/replicas link
  ProjectControl: Allow regexes ref strings for uploads
  ProjectControl: Allow regexes ref strings for tags
  ProjectControl: Reuse constants for ref strings
  Add extension point to gr-user-header

Change-Id: Ib67847e1f8d972543b6b0491e270f6e501cc63b1
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 5bdd1a7..0023c3f 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2888,15 +2888,15 @@
 [[elasticsearch.numberOfShards]]elasticsearch.numberOfShards::
 +
 Sets the number of shards to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/_basic_concepts.html#getting-started-shards-and-replicas[
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-concepts.html#getting-started-shards-and-replicas[
 Elasticsearch documentation] for details.
 +
-Defaults to 5.
+Defaults to 5 for Elasticsearch versions 5 and 6, and to 1 starting with Elasticsearch 7.
 
 [[elasticsearch.numberOfReplicas]]elasticsearch.numberOfReplicas::
 +
 Sets the number of replicas to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/_basic_concepts.html#getting-started-shards-and-replicas[
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-concepts.html#getting-started-shards-and-replicas[
 Elasticsearch documentation] for details.
 +
 Defaults to 1.
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index e215759..996bbfd 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -198,7 +198,7 @@
     }
 
     // Recreate the index.
-    String indexCreationFields = concatJsonString(getSettings(), getMappings());
+    String indexCreationFields = concatJsonString(getSettings(client.adapter()), getMappings());
     response =
         performRequest(
             "PUT", indexName + client.adapter().includeTypeNameParam(), indexCreationFields);
@@ -213,8 +213,8 @@
 
   protected abstract String getMappings();
 
-  private String getSettings() {
-    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting(config)));
+  private String getSettings(ElasticQueryAdapter adapter) {
+    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting(config, adapter)));
   }
 
   protected abstract String getId(V v);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index 5f48499..cbe9bc7 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -42,7 +42,7 @@
   static final String KEY_NUMBER_OF_REPLICAS = "numberOfReplicas";
   static final String DEFAULT_PORT = "9200";
   static final String DEFAULT_USERNAME = "elastic";
-  static final int DEFAULT_NUMBER_OF_SHARDS = 5;
+  static final int DEFAULT_NUMBER_OF_SHARDS = 0;
   static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
 
   private final Config cfg;
@@ -100,4 +100,11 @@
   String getIndexName(String name, int schemaVersion) {
     return String.format("%s%s_%04d", prefix, name, schemaVersion);
   }
+
+  int getNumberOfShards(ElasticQueryAdapter adapter) {
+    if (numberOfShards == DEFAULT_NUMBER_OF_SHARDS) {
+      return adapter.getDefaultNumberOfShards();
+    }
+    return numberOfShards;
+  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
index b015678..72c52b0 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -27,6 +27,7 @@
   private final boolean useV5Type;
   private final boolean useV6Type;
   private final boolean omitType;
+  private final int defaultNumberOfShards;
 
   private final String searchFilteringName;
   private final String indicesExistParams;
@@ -41,6 +42,7 @@
     this.useV5Type = !version.isV6OrLater();
     this.useV6Type = version.isV6();
     this.omitType = version.isV7OrLater();
+    this.defaultNumberOfShards = version.isV7OrLater() ? 1 : 5;
     this.versionDiscoveryUrl = version.isV6OrLater() ? "/%s*" : "/%s*/_aliases";
     this.searchFilteringName = "_source";
     this.indicesExistParams =
@@ -98,6 +100,10 @@
     return omitType;
   }
 
+  int getDefaultNumberOfShards() {
+    return defaultNumberOfShards;
+  }
+
   String getType() {
     return getType("");
   }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
index 98c313c..14e4623 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticSetting.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
@@ -22,18 +22,18 @@
   private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
       ImmutableMap.of("\\u002E", "\\u0020", "\\u005F", "\\u0020");
 
-  static SettingProperties createSetting(ElasticConfiguration config) {
-    return new ElasticSetting.Builder().addCharFilter().addAnalyzer().build(config);
+  static SettingProperties createSetting(ElasticConfiguration config, ElasticQueryAdapter adapter) {
+    return new ElasticSetting.Builder().addCharFilter().addAnalyzer().build(config, adapter);
   }
 
   static class Builder {
     private final ImmutableMap.Builder<String, FieldProperties> fields =
         new ImmutableMap.Builder<>();
 
-    SettingProperties build(ElasticConfiguration config) {
+    SettingProperties build(ElasticConfiguration config, ElasticQueryAdapter adapter) {
       SettingProperties properties = new SettingProperties();
       properties.analysis = fields.build();
-      properties.numberOfShards = config.numberOfShards;
+      properties.numberOfShards = config.getNumberOfShards(adapter);
       properties.numberOfReplicas = config.numberOfReplicas;
       return properties;
     }
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 066a3ca..740daf0 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -47,10 +47,32 @@
     return getWebUrl().map(url -> url + "c/" + project.get() + "/+/" + id.get());
   }
 
+  /** Returns the URL for viewing a file in a given patch set of a change. */
+  default Optional<String> getPatchFileView(Change change, int patchsetId, String filename) {
+    return getChangeViewUrl(change.getProject(), change.getId())
+        .map(url -> url + "/" + patchsetId + "/" + filename);
+  }
+
+  /** Returns the URL for viewing a comment in a file in a given patch set of a change. */
+  default Optional<String> getInlineCommentView(
+      Change change, int patchsetId, String filename, short side, int startLine) {
+    return getPatchFileView(change, patchsetId, filename)
+        .map(url -> url + String.format("@%s%d", side == 0 ? "a" : "", startLine));
+  }
+
   /** Returns a URL pointing to a section of the settings page. */
+  default Optional<String> getSettingsUrl() {
+    return getWebUrl().map(url -> url + "settings");
+  }
+
+  /**
+   * Returns a URL pointing to a section of the settings page, or the settings page if {@code
+   * section} is null.
+   */
   default Optional<String> getSettingsUrl(@Nullable String section) {
-    return getWebUrl()
-        .map(url -> url + "settings" + (Strings.isNullOrEmpty(section) ? "" : "#" + section));
+    return Strings.isNullOrEmpty(section)
+        ? getSettingsUrl()
+        : getSettingsUrl().map(url -> url + "#" + section);
   }
 
   /** Returns a URL pointing to a documentation page, at a given named anchor. */
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index e1c7dc9..c2fbb85 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
+import com.google.common.base.Objects;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -40,7 +41,9 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -70,6 +73,11 @@
   private final StalenessChecker stalenessChecker;
   private final boolean autoReindexIfStale;
 
+  private final Set<IndexTask> queuedIndexTasks =
+      Collections.newSetFromMap(new ConcurrentHashMap<>());
+  private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
+      Collections.newSetFromMap(new ConcurrentHashMap<>());
+
   @AssistedInject
   ChangeIndexer(
       @GerritServerConfig Config cfg,
@@ -123,7 +131,11 @@
    * @return future for the indexing task.
    */
   public ListenableFuture<?> indexAsync(Project.NameKey project, Change.Id id) {
-    return submit(new IndexTask(project, id));
+    IndexTask task = new IndexTask(project, id);
+    if (queuedIndexTasks.add(task)) {
+      return submit(task);
+    }
+    return Futures.immediateFuture(null);
   }
 
   /**
@@ -242,7 +254,11 @@
    * @return future for reindexing the change; returns true if the change was stale.
    */
   public ListenableFuture<Boolean> reindexIfStale(Project.NameKey project, Change.Id id) {
-    return submit(new ReindexIfStaleTask(project, id), batchExecutor);
+    ReindexIfStaleTask task = new ReindexIfStaleTask(project, id);
+    if (queuedReindexIfStaleTasks.add(task)) {
+      return submit(task, batchExecutor);
+    }
+    return Futures.immediateFuture(false);
   }
 
   private void autoReindexIfStale(ChangeData cd) {
@@ -281,6 +297,8 @@
 
     protected abstract T callImpl() throws Exception;
 
+    protected abstract void remove();
+
     @Override
     public abstract String toString();
 
@@ -311,15 +329,35 @@
 
     @Override
     public Void callImpl() throws Exception {
+      remove();
       ChangeData cd = changeDataFactory.create(project, id);
       index(cd);
       return null;
     }
 
     @Override
+    public int hashCode() {
+      return Objects.hashCode(IndexTask.class, id.get());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (!(obj instanceof IndexTask)) {
+        return false;
+      }
+      IndexTask other = (IndexTask) obj;
+      return id.get() == other.id.get();
+    }
+
+    @Override
     public String toString() {
       return "index-change-" + id;
     }
+
+    @Override
+    protected void remove() {
+      queuedIndexTasks.remove(this);
+    }
   }
 
   // Not AbstractIndexTask as it doesn't need a request context.
@@ -359,6 +397,7 @@
 
     @Override
     public Boolean callImpl() throws Exception {
+      remove();
       try {
         if (stalenessChecker.isStale(id)) {
           indexImpl(changeDataFactory.create(project, id));
@@ -376,9 +415,28 @@
     }
 
     @Override
+    public int hashCode() {
+      return Objects.hashCode(ReindexIfStaleTask.class, id.get());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (!(obj instanceof ReindexIfStaleTask)) {
+        return false;
+      }
+      ReindexIfStaleTask other = (ReindexIfStaleTask) obj;
+      return id.get() == other.id.get();
+    }
+
+    @Override
     public String toString() {
       return "reindex-if-stale-change-" + id;
     }
+
+    @Override
+    protected void remove() {
+      queuedReindexIfStaleTasks.remove(this);
+    }
   }
 
   private boolean isCausedByRepositoryNotFoundException(Throwable throwable) {
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 32d63fc..21579d2 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -17,6 +17,7 @@
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
+import com.google.common.base.Objects;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
@@ -42,8 +43,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
+import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Config;
 
@@ -61,6 +65,8 @@
   private final ListeningExecutorService executor;
   private final boolean enabled;
 
+  private final Set<Index> queuedIndexTasks = Collections.newSetFromMap(new ConcurrentHashMap<>());
+
   @Inject
   ReindexAfterRefUpdate(
       @GerritServerConfig Config cfg,
@@ -107,9 +113,12 @@
           @Override
           public void onSuccess(List<Change> changes) {
             for (Change c : changes) {
-              // Don't retry indefinitely; if this fails changes may be stale.
-              @SuppressWarnings("unused")
-              Future<?> possiblyIgnoredError = executor.submit(new Index(event, c.getId()));
+              Index task = new Index(event, c.getId());
+              if (queuedIndexTasks.add(task)) {
+                // Don't retry indefinitely; if this fails changes may be stale.
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError = executor.submit(task);
+              }
             }
           }
 
@@ -139,6 +148,8 @@
     }
 
     protected abstract V impl(RequestContext ctx) throws Exception;
+
+    protected abstract void remove();
   }
 
   private class GetChanges extends Task<List<Change>> {
@@ -163,6 +174,9 @@
           + " update of project "
           + event.getProjectName();
     }
+
+    @Override
+    protected void remove() {}
   }
 
   private class Index extends Task<Void> {
@@ -176,6 +190,7 @@
     @Override
     protected Void impl(RequestContext ctx) throws IOException {
       // Reload change, as some time may have passed since GetChanges.
+      remove();
       try {
         Change c =
             notesFactory.createChecked(Project.nameKey(event.getProjectName()), id).getChange();
@@ -187,8 +202,27 @@
     }
 
     @Override
+    public int hashCode() {
+      return Objects.hashCode(Index.class, id.get());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (!(obj instanceof Index)) {
+        return false;
+      }
+      Index other = (Index) obj;
+      return id.get() == other.id.get();
+    }
+
+    @Override
     public String toString() {
       return "Index change " + id.get() + " of project " + event.getProjectName();
     }
+
+    @Override
+    protected void remove() {
+      queuedIndexTasks.remove(this);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 5b25ebe1..f8c29b3 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -75,21 +75,19 @@
     public List<Comment> comments = new ArrayList<>();
 
     /** @return a web link to the given patch set and file. */
-    public String getLink() {
-      String url = getGerritUrl();
-      if (url == null) {
-        return null;
-      }
+    public String getFileLink() {
+      return args.urlFormatter
+          .get()
+          .getPatchFileView(change, patchSetId, KeyUtil.encode(filename))
+          .orElse(null);
+    }
 
-      return new StringBuilder()
-          .append(url)
-          .append("#/c/")
-          .append(change.getId())
-          .append('/')
-          .append(patchSetId)
-          .append('/')
-          .append(KeyUtil.encode(filename))
-          .toString();
+    /** @return a web link to a comment within a given patch set and file. */
+    public String getCommentLink(short side, int startLine) {
+      return args.urlFormatter
+          .get()
+          .getInlineCommentView(change, patchSetId, KeyUtil.encode(filename), side, startLine)
+          .orElse(null);
     }
 
     /**
@@ -391,7 +389,7 @@
 
     for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
-      groupData.put("link", group.getLink());
+      groupData.put("link", group.getFileLink());
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
 
@@ -420,11 +418,9 @@
 
         // Set the comment link.
         if (comment.lineNbr == 0) {
-          commentData.put("link", group.getLink());
-        } else if (comment.side == 0) {
-          commentData.put("link", group.getLink() + "@a" + startLine);
+          commentData.put("link", group.getFileLink());
         } else {
-          commentData.put("link", group.getLink() + '@' + startLine);
+          commentData.put("link", group.getCommentLink(comment.side, startLine));
         }
 
         // Set robot comment data.
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index ef6b21d..4313473 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -282,16 +282,10 @@
   }
 
   public String getSettingsUrl() {
-    if (getGerritUrl() != null) {
-      final StringBuilder r = new StringBuilder();
-      r.append(getGerritUrl());
-      r.append("settings");
-      return r.toString();
-    }
-    return null;
+    return args.urlFormatter.get().getSettingsUrl().orElse(null);
   }
 
-  public String getGerritUrl() {
+  private String getGerritUrl() {
     return args.urlFormatter.get().getWebUrl().orElse(null);
   }
 
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index bc00b88..5ebe673 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -15,7 +15,10 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.common.data.AccessSection.ALL;
+import static com.google.gerrit.common.data.AccessSection.REGEX_PREFIX;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
+import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
 
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.AccessSection;
@@ -139,7 +142,7 @@
 
   /** Is this user a project owner? */
   boolean isOwner() {
-    return (isDeclaredOwner() && controlForRef("refs/*").canPerform(Permission.OWNER)) || isAdmin();
+    return (isDeclaredOwner() && controlForRef(ALL).canPerform(Permission.OWNER)) || isAdmin();
   }
 
   /**
@@ -200,7 +203,8 @@
   private boolean canCreateChanges() {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.getSection();
-      if (section.getName().startsWith("refs/for/")) {
+      if (section.getName().startsWith(NEW_CHANGE)
+          || section.getName().startsWith(REGEX_PREFIX + NEW_CHANGE)) {
         Permission permission = section.getPermission(Permission.PUSH);
         if (permission != null && controlForRef(section.getName()).canPerform(Permission.PUSH)) {
           return true;
@@ -222,7 +226,8 @@
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.getSection();
 
-      if (section.getName().startsWith(REFS_TAGS)) {
+      if (section.getName().startsWith(REFS_TAGS)
+          || section.getName().startsWith(REGEX_PREFIX + REFS_TAGS)) {
         Permission permission = section.getPermission(permissionName);
         if (permission == null) {
           continue;
@@ -276,7 +281,7 @@
   private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
     boolean canPerform = false;
     Set<String> patterns = allRefPatterns(permission);
-    if (patterns.contains(AccessSection.ALL)) {
+    if (patterns.contains(ALL)) {
       // Only possible if granted on the pattern that
       // matches every possible reference.  Check all
       // patterns also have the permission.
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index c27bb69..c9a6a10 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -84,6 +84,8 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.testing.EditInfoSubject;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -94,8 +96,12 @@
 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.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.CommitValidators.ChangeIdValidator;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.testing.TestLabels;
@@ -105,6 +111,7 @@
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.EnumSet;
 import java.util.HashMap;
@@ -112,6 +119,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -152,6 +160,8 @@
   private static String NEW_CHANGE_INDICATOR = " [NEW]";
   private LabelType patchSetLock;
 
+  @Inject private DynamicSet<CommitValidationListener> commitValidators;
+
   @BeforeClass
   public static void setTimeForTesting() {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -2330,6 +2340,57 @@
         .isEqualTo(Iterables.getLast(commits).name());
   }
 
+  private static class TestValidator implements CommitValidationListener {
+    private final AtomicInteger count = new AtomicInteger();
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      count.incrementAndGet();
+      return Collections.emptyList();
+    }
+
+    public int count() {
+      return count.get();
+    }
+  }
+
+  @Test
+  public void skipValidation() throws Exception {
+    String master = "refs/heads/master";
+    TestValidator validator = new TestValidator();
+    RegistrationHandle handle = commitValidators.add("test-validator", validator);
+
+    try {
+      // Validation listener is called on normal push
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r = push.to(master);
+      r.assertOkStatus();
+      assertThat(validator.count()).isEqualTo(1);
+
+      // Push is rejected and validation listener is not called when not allowed
+      // to use skip option
+      PushOneCommit push2 =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push2.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+      r = push2.to(master);
+      r.assertErrorStatus("not permitted: skip validation");
+      assertThat(validator.count()).isEqualTo(1);
+
+      // Validation listener is not called when skip option is used
+      grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
+      PushOneCommit push3 =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push3.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+      r = push3.to(master);
+      r.assertOkStatus();
+      assertThat(validator.count()).isEqualTo(1);
+    } finally {
+      handle.remove();
+    }
+  }
+
   @Test
   public void pushNoteDbRef() throws Exception {
     String ref = "refs/changes/34/1234/meta";
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 632c094..085cfea 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -653,13 +653,17 @@
                 + "comments\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/1/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/1/a.txt@a2 \n"
                 + "PS1, Line 2: \n"
@@ -667,7 +671,9 @@
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/1/a.txt@1 \n"
                 + "PS1, Line 1: boring\n"
@@ -675,13 +681,17 @@
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt@a1 \n"
                 + "PS2, Line 1: \n"
@@ -689,7 +699,9 @@
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt@a2 \n"
                 + "PS2, Line 2: \n"
@@ -697,7 +709,9 @@
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt@1 \n"
                 + "PS2, Line 1: interesting\n"
@@ -705,7 +719,9 @@
                 + "\n"
                 + "\n"
                 + url
-                + "#/c/"
+                + "c/"
+                + project.get()
+                + "/+/"
                 + c
                 + "/2/a.txt@2 \n"
                 + "PS2, Line 2: nten\n"
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
index 49faad5..fed1c12 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
@@ -16,12 +16,14 @@
 -->
 
 <link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/dashboard-header-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-user-header">
   <template>
@@ -62,6 +64,12 @@
             date-str="[[_computeDetail(_accountDetails, 'registered_on')]]">
         </gr-date-formatter>
       </div>
+      <gr-endpoint-decorator name="user-header">
+        <gr-endpoint-param name="accountDetails" value="[[_accountDetails]]">
+        </gr-endpoint-param>
+        <gr-endpoint-param name="loggedIn" value="[[loggedIn]]">
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
     </div>
     <div class="info">
       <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">