Merge "Don't return single change ref if skipMetadata is set"
diff --git a/.mailmap b/.mailmap
index cbf1f3b..c863847 100644
--- a/.mailmap
+++ b/.mailmap
@@ -12,6 +12,7 @@
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
 Changcheng Xiao <xchangcheng@google.com>                                                    xchangcheng
 Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
+Darrien Glasser <darrien@arista.com>                                                        darrien <darrien@arista.com>
 Dave Borowitz <dborowitz@google.com>                                                        <dborowitz@google.com>
 David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
 David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index c514321..dfd215e 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -1358,7 +1358,8 @@
 any of their groups is used.
 
 This limit applies not only to the link:cmd-query.html[`gerrit query`]
-command, but also to the web UI results pagination size.
+command, but also to the web UI results pagination size in the new
+PolyGerrit UI.
 
 
 [[capability_readAs]]
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 681b5ff..b15aea7 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -147,6 +147,11 @@
 $ ssh -p 29418 review.example.com gerrit review --verified +1 8242,2
 ----
 
+Approve the change with change number 8242 and patch set 2 as "Code-Review +2"
+----
+$ ssh -p 29418 review.example.com gerrit review --code-review +2 8242,2
+----
+
 Vote on the project specific label "mylabel":
 ----
 $ ssh -p 29418 review.example.com gerrit review --label mylabel=+1 8242,2
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index efa17da..bec1984 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -286,33 +286,6 @@
   bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
-The tests run with NoteDb fully enabled by default.
-
-To run the tests against NoteDb backend with write to NoteDb, but not read from
-it:
-
-----
-  bazel test --test_env=GERRIT_NOTEDB=WRITE //...
-----
-
-Write and read from NoteDb:
-
-----
-  bazel test --test_env=GERRIT_NOTEDB=READ_WRITE //...
-----
-
-Primary storage NoteDb:
-
-----
-  bazel test --test_env=GERRIT_NOTEDB=PRIMARY //...
-----
-
-NoteDb entirely disabled:
-
-----
-  bazel test --test_env=GERRIT_NOTEDB=OFF //...
-----
-
 To run only tests that do not use SSH:
 
 ----
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index ea51977..ca47690 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -184,10 +184,6 @@
 plugin manages a project, it intercepts the creation and creates a Bazel test
 run configuration instead, which can be used just like the standard ones.
 
-TIP: Tests run with NoteDb enabled by default. If you would like to execute a
-test with NoteDb turned off, add `--test_env=GERRIT_NOTEDB=OFF` to the *Bazel
-flags* of your run configuration.
-
 [[remote-debug]]
 === Debugging a remote Gerrit server
 If a remote Gerrit server is running and has opened a debug port, you can attach
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index abd2531..5d7a78b 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -276,6 +276,8 @@
 ones using a bracket expression). For example, to match all XML
 files named like 'name1.xml', 'name2.xml', and 'name3.xml' use
 `file:"^name[1-3].xml"`.
++
+Slash ('/') is used path separator.
 
 [[file]]
 file:'NAME', f:'NAME'::
@@ -294,8 +296,40 @@
 +
 Matches any change touching a file with extension 'EXT', case-insensitive. The
 extension is defined as the portion of the filename following the final `.`.
-Files with no `.` in their name have no extension and cannot be matched with
-this operator; use `file:` instead.
+Files with no `.` in their name have no extension and can be matched by an
+empty string.
+
+[[onlyextensions]]
+onlyextensions:'EXT_LIST', onlyexts:'EXT_LIST'::
++
+Matches any change touching only files with extensions that are listed in
+'EXT_LIST' (comma-separated list). The matching is done case-insensitive.
+An extension is defined as the portion of the filename following the final `.`.
+Files with no `.` in their name have no extension and can be matched by an
+empty string.
+
+[[directory]]
+directory:'DIR', dir:'DIR'::
++
+Matches any change where the current patch set touches a file in the directory
+'DIR'. The matching is done case-insensitive. 'DIR' can be a full directory
+name, a directory prefix or any combination of intermediate directory segments.
+E.g. a change that touches a file in the directory 'a/b/c' matches for 'a/b/c',
+'a', 'a/b', 'b', 'b/c' and 'c'.
++
+Slash ('/') is used path separator. Leading and trailing slashes are allowed
+but are not mandatory.
++
+If 'DIR' starts with `^` it matches directories and directory segments by
+regular expression. The link:http://www.brics.dk/automaton/[dk.brics.automaton
+library] is used for evaluation of such patterns.
+
+[[footer]]
+footer:'FOOTER'::
++
+Matches any change that has 'FOOTER' as footer in the commit message of the
+current patch set. 'FOOTER' can be specified verbatim ('<key>: <value>', must
+be quoted) or as '<key>=<value>'. The matching is done case-insensitive.
 
 [[star]]
 star:'LABEL'::
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index a6df45f..7d356bf 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -150,6 +152,9 @@
 
   RelatedChangesInfo related() throws RestApiException;
 
+  /** Returns votes on the revision. */
+  ListMultimap<String, ApprovalInfo> votes() throws RestApiException;
+
   abstract class MergeListRequest {
     private boolean addLinks;
     private int uninterestingParent = 1;
@@ -361,6 +366,11 @@
     }
 
     @Override
+    public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void description(String description) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index 703235d..e40004b 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
 
 public class ApprovalInfo extends AccountInfo {
@@ -28,7 +29,11 @@
   }
 
   public ApprovalInfo(
-      Integer id, Integer value, VotingRangeInfo permittedVotingRange, String tag, Timestamp date) {
+      Integer id,
+      Integer value,
+      @Nullable VotingRangeInfo permittedVotingRange,
+      @Nullable String tag,
+      Timestamp date) {
     super(id);
     this.value = value;
     this.permittedVotingRange = permittedVotingRange;
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 3759636..e2fd7f3 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.gerrit.common.Version.getVersion;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -364,7 +366,15 @@
   }
 
   private String myVersion() {
-    return com.google.gerrit.common.Version.getVersion();
+    List<String> versionParts = new ArrayList<>();
+    if (slave) {
+      versionParts.add("[slave]");
+    }
+    if (headless) {
+      versionParts.add("[headless]");
+    }
+    versionParts.add(getVersion());
+    return Joiner.on(" ").join(versionParts);
   }
 
   private Injector createCfgInjector() {
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index f8a2ecb..2df7ae6 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder.ListMultimapBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
@@ -36,6 +38,7 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -51,6 +54,10 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -85,6 +92,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -135,6 +143,8 @@
   private final GetRelated getRelated;
   private final PutDescription putDescription;
   private final GetDescription getDescription;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountLoader.Factory accountLoaderFactory;
 
   @Inject
   RevisionApiImpl(
@@ -176,6 +186,8 @@
       GetRelated getRelated,
       PutDescription putDescription,
       GetDescription getDescription,
+      ApprovalsUtil approvalsUtil,
+      AccountLoader.Factory accountLoaderFactory,
       @Assisted RevisionResource r) {
     this.repoManager = repoManager;
     this.changes = changes;
@@ -215,6 +227,8 @@
     this.getRelated = getRelated;
     this.putDescription = putDescription;
     this.getDescription = getDescription;
+    this.approvalsUtil = approvalsUtil;
+    this.accountLoaderFactory = accountLoaderFactory;
     this.revision = r;
   }
 
@@ -568,6 +582,36 @@
   }
 
   @Override
+  public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
+    ListMultimap<String, ApprovalInfo> result =
+        ListMultimapBuilder.treeKeys().arrayListValues().build();
+    try {
+      Iterable<PatchSetApproval> approvals =
+          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().getId(), null, null);
+      AccountLoader accountLoader =
+          accountLoaderFactory.create(
+              EnumSet.of(
+                  FillOptions.ID, FillOptions.NAME, FillOptions.EMAIL, FillOptions.USERNAME));
+      for (PatchSetApproval approval : approvals) {
+        String label = approval.getLabel();
+        ApprovalInfo info =
+            new ApprovalInfo(
+                approval.getAccountId().get(),
+                Integer.valueOf(approval.getValue()),
+                null,
+                approval.getTag(),
+                approval.getGranted());
+        accountLoader.put(info);
+        result.get(label).add(info);
+      }
+      accountLoader.fill();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get votes", e);
+    }
+    return result;
+  }
+
+  @Override
   public void description(String description) throws RestApiException {
     DescriptionInput in = new DescriptionInput();
     in.description = description;
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 1e6b94d..721d878 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.project.ListProjects;
@@ -155,7 +154,7 @@
           .withQuery(r.getQuery())
           .withLimit(r.getLimit())
           .withStart(r.getStart())
-          .apply(TopLevelResource.INSTANCE);
+          .apply();
     } catch (OrmException e) {
       throw new RestApiException("Cannot query projects", e);
     }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index 8fc7102..e4b1f28 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -227,8 +227,7 @@
       }
     }
 
-    if (byAccountId != null
-        && wholeGroup != null
+    if (wholeGroup != null
         && byAccountId.failureType == FailureType.NOT_FOUND
         && wholeGroup.failureType == FailureType.NOT_FOUND) {
       return fail(
@@ -237,7 +236,7 @@
           byAccountId.result.error + "\n" + wholeGroup.result.error);
     }
 
-    if (byAccountId != null && byAccountId.failureType != FailureType.NOT_FOUND) {
+    if (byAccountId.failureType != FailureType.NOT_FOUND) {
       return byAccountId;
     }
     if (wholeGroup != null) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 52dac9d..593fb85 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.index.FieldDef.storedOnly;
 import static com.google.gerrit.index.FieldDef.timestamp;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
@@ -191,6 +192,29 @@
       exact(ChangeQueryBuilder.FIELD_EXTENSION).buildRepeatable(ChangeField::getExtensions);
 
   public static Set<String> getExtensions(ChangeData cd) throws OrmException {
+    return extensions(cd).collect(toSet());
+  }
+
+  /**
+   * File extensions of each file modified in the current patch set as a sorted list. The purpose of
+   * this field is to allow matching changes that only touch files with certain file extensions.
+   */
+  public static final FieldDef<ChangeData, String> ONLY_EXTENSIONS =
+      exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS).build(ChangeField::getAllExtensionsAsList);
+
+  public static String getAllExtensionsAsList(ChangeData cd) throws OrmException {
+    return extensions(cd).distinct().sorted().collect(joining(","));
+  }
+
+  /**
+   * Returns a stream with all file extensions that are used by files in the given change. A file
+   * extension is defined as the portion of the filename following the final `.`. Files with no `.`
+   * in their name have no extension. For them an empty string is returned as part of the stream.
+   *
+   * <p>If the change contains multiple files with the same extension the extension is returned
+   * multiple times in the stream (once per file).
+   */
+  private static Stream<String> extensions(ChangeData cd) throws OrmException {
     try {
       return cd.currentFilePaths()
           .stream()
@@ -198,14 +222,69 @@
           // If we want to find "all Java files", we want to match both .java and .JAVA, even if we
           // normally care about case sensitivity. (Whether we should change the existing file/path
           // predicates to be case insensitive is a separate question.)
-          .map(f -> Files.getFileExtension(f).toLowerCase(Locale.US))
-          .filter(e -> !e.isEmpty())
+          .map(f -> Files.getFileExtension(f).toLowerCase(Locale.US));
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  /** Footers from the commit message of the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> FOOTER =
+      exact(ChangeQueryBuilder.FIELD_FOOTER).buildRepeatable(ChangeField::getFooters);
+
+  public static Set<String> getFooters(ChangeData cd) throws OrmException {
+    try {
+      return cd.commitFooters()
+          .stream()
+          .map(f -> f.toString().toLowerCase(Locale.US))
           .collect(toSet());
     } catch (IOException e) {
       throw new OrmException(e);
     }
   }
 
+  /** Folders that are touched by the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> DIRECTORY =
+      exact(ChangeQueryBuilder.FIELD_DIRECTORY).buildRepeatable(ChangeField::getDirectories);
+
+  public static Set<String> getDirectories(ChangeData cd) throws OrmException {
+    List<String> paths;
+    try {
+      paths = cd.currentFilePaths();
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+
+    Splitter s = Splitter.on('/').omitEmptyStrings();
+    Set<String> r = new HashSet<>();
+    for (String path : paths) {
+      StringBuilder directory = new StringBuilder();
+      directory.append("");
+      r.add(directory.toString());
+      String nextPart = null;
+      for (String part : s.split(path)) {
+        if (nextPart != null) {
+          r.add(nextPart);
+
+          if (directory.length() > 0) {
+            directory.append("/");
+          }
+          directory.append(nextPart);
+
+          String intermediateDir = directory.toString();
+          int i = intermediateDir.indexOf('/');
+          while (i >= 0) {
+            r.add(intermediateDir);
+            intermediateDir = intermediateDir.substring(i + 1);
+            i = intermediateDir.indexOf('/');
+          }
+        }
+        nextPart = part;
+      }
+    }
+    return r;
+  }
+
   /** Owner/creator of the change. */
   public static final FieldDef<ChangeData, Integer> OWNER =
       integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index cd24c92..cde6a64 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -103,7 +103,16 @@
 
   @Deprecated static final Schema<ChangeData> V51 = schema(V50, ChangeField.TOTAL_COMMENT_COUNT);
 
-  static final Schema<ChangeData> V52 = schema(V51, ChangeField.EXTENSION);
+  @Deprecated static final Schema<ChangeData> V52 = schema(V51, ChangeField.EXTENSION);
+
+  @Deprecated static final Schema<ChangeData> V53 = schema(V52, ChangeField.ONLY_EXTENSIONS);
+
+  @Deprecated static final Schema<ChangeData> V54 = schema(V53, ChangeField.FOOTER);
+
+  @Deprecated static final Schema<ChangeData> V55 = schema(V54, ChangeField.DIRECTORY);
+
+  // The computation of the 'extension' field is changed, hence reindexing is required.
+  static final Schema<ChangeData> V56 = schema(V55);
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/mail/MailUtil.java b/java/com/google/gerrit/server/mail/MailUtil.java
index d50df80..3a5a2cf 100644
--- a/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/java/com/google/gerrit/server/mail/MailUtil.java
@@ -60,6 +60,7 @@
     return recipients;
   }
 
+  @SuppressWarnings("deprecation")
   private static Account.Id toAccountId(AccountResolver accountResolver, String nameOrEmail)
       throws OrmException, UnprocessableEntityException, IOException, ConfigInvalidException {
     return accountResolver.resolveByNameOrEmail(nameOrEmail).asUnique().getAccount().getId();
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 8bb6694..9fdc7e8 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -1542,6 +1543,7 @@
     return m.stream().sorted().collect(toImmutableList());
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public boolean hasLegacyPermissions() {
     return hasLegacyPermissions;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 0b889dc..b62a1d7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -138,8 +138,11 @@
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
   public static final String FIELD_COMMITTER = "committer";
+  public static final String FIELD_DIRECTORY = "directory";
   public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
   public static final String FIELD_EXTENSION = "extension";
+  public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
+  public static final String FIELD_FOOTER = "footer";
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
@@ -749,6 +752,45 @@
   }
 
   @Operator
+  public Predicate<ChangeData> onlyexts(String extList) throws QueryParseException {
+    return onlyextensions(extList);
+  }
+
+  @Operator
+  public Predicate<ChangeData> onlyextensions(String extList) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.ONLY_EXTENSIONS)) {
+      return new FileExtensionListPredicate(extList);
+    }
+    throw new QueryParseException(
+        "'onlyextensions' operator is not supported by change index version");
+  }
+
+  @Operator
+  public Predicate<ChangeData> footer(String footer) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.FOOTER)) {
+      return new FooterPredicate(footer);
+    }
+    throw new QueryParseException("'footer' operator is not supported by change index version");
+  }
+
+  @Operator
+  public Predicate<ChangeData> dir(String directory) throws QueryParseException {
+    return directory(directory);
+  }
+
+  @Operator
+  public Predicate<ChangeData> directory(String directory) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.DIRECTORY)) {
+      if (directory.startsWith("^")) {
+        return new RegexDirectoryPredicate(directory);
+      }
+
+      return new DirectoryPredicate(directory);
+    }
+    throw new QueryParseException("'directory' operator is not supported by change index version");
+  }
+
+  @Operator
   public Predicate<ChangeData> label(String name)
       throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     Set<Account.Id> accounts = null;
diff --git a/java/com/google/gerrit/server/query/change/DirectoryPredicate.java b/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
new file mode 100644
index 0000000..676a208
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.base.CharMatcher;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+import java.util.Locale;
+
+public class DirectoryPredicate extends ChangeIndexPredicate {
+  private static String clean(String directory) {
+    return CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US);
+  }
+
+  DirectoryPredicate(String value) {
+    super(ChangeField.DIRECTORY, clean(value));
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return ChangeField.getDirectories(cd).contains(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
new file mode 100644
index 0000000..3399338
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Splitter;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+
+public class FileExtensionListPredicate extends ChangeIndexPredicate {
+  private static String clean(String extList) {
+    return Splitter.on(',')
+        .splitToList(extList)
+        .stream()
+        .map(FileExtensionPredicate::clean)
+        .distinct()
+        .sorted()
+        .collect(joining(","));
+  }
+
+  FileExtensionListPredicate(String value) {
+    super(ChangeField.ONLY_EXTENSIONS, clean(value));
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return ChangeField.getAllExtensionsAsList(cd).equals(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
index ee5030a..5353f11 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
@@ -19,7 +19,7 @@
 import java.util.Locale;
 
 public class FileExtensionPredicate extends ChangeIndexPredicate {
-  private static String clean(String ext) {
+  static String clean(String ext) {
     if (ext.startsWith(".")) {
       ext = ext.substring(1);
     }
diff --git a/java/com/google/gerrit/server/query/change/FooterPredicate.java b/java/com/google/gerrit/server/query/change/FooterPredicate.java
new file mode 100644
index 0000000..1d7d19b
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FooterPredicate.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+import java.util.Locale;
+
+public class FooterPredicate extends ChangeIndexPredicate {
+  private static String clean(String value) {
+    int indexEquals = value.indexOf('=');
+    int indexColon = value.indexOf(':');
+
+    // footer key cannot contain '='
+    if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
+      value = value.substring(0, indexEquals) + ": " + value.substring(indexEquals + 1);
+    }
+    return value.toLowerCase(Locale.US);
+  }
+
+  FooterPredicate(String value) {
+    super(ChangeField.FOOTER, clean(value));
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return ChangeField.getFooters(cd).contains(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
new file mode 100644
index 0000000..1d49f1e
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+public class RegexDirectoryPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
+
+  public RegexDirectoryPredicate(String re) {
+    super(ChangeField.DIRECTORY, re);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return ChangeField.getDirectories(cd).stream().anyMatch(pattern::run);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index 571c319..092f118 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -54,7 +55,7 @@
 
   @Override
   protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, @Nullable SetPrivateOp.Input input)
       throws RestApiException, UpdateException {
     if (!canDeletePrivate(rsrc).value()) {
       throw new AuthException("not allowed to unmark private");
diff --git a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
index 04c94be..3bb297d 100644
--- a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -43,14 +44,14 @@
   }
 
   public interface Factory {
-    SetPrivateOp create(ChangeMessagesUtil cmUtil, boolean isPrivate, Input input);
+    SetPrivateOp create(ChangeMessagesUtil cmUtil, boolean isPrivate, @Nullable Input input);
   }
 
-  private final ChangeMessagesUtil cmUtil;
-  private final PatchSetUtil psUtil;
-  private final boolean isPrivate;
-  private final Input input;
   private final PrivateStateChanged privateStateChanged;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final boolean isPrivate;
+  @Nullable private final Input input;
 
   private Change change;
   private PatchSet ps;
@@ -61,12 +62,12 @@
       PatchSetUtil psUtil,
       @Assisted ChangeMessagesUtil cmUtil,
       @Assisted boolean isPrivate,
-      @Assisted Input input) {
-    this.cmUtil = cmUtil;
+      @Assisted @Nullable Input input) {
+    this.privateStateChanged = privateStateChanged;
     this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
     this.isPrivate = isPrivate;
     this.input = input;
-    this.privateStateChanged = privateStateChanged;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
index 12f747b..cfeec5a 100644
--- a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -90,7 +89,7 @@
         .get()
         .withQuery("parent:" + parent.get())
         .withLimit(limit)
-        .apply(TopLevelResource.INSTANCE)
+        .apply()
         .stream()
         .filter(
             p ->
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index 5561d4a..44432aa 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -89,6 +89,11 @@
   @Override
   public List<ProjectInfo> apply(TopLevelResource resource)
       throws BadRequestException, MethodNotAllowedException, OrmException {
+    return apply();
+  }
+
+  public List<ProjectInfo> apply()
+      throws BadRequestException, MethodNotAllowedException, OrmException {
     if (Strings.isNullOrEmpty(query)) {
       throw new BadRequestException("missing query field");
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 3507714..e8c828a 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -35,6 +35,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Iterators;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
@@ -1460,6 +1461,47 @@
         .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
   }
 
+  @Test
+  public void listVotesByRevision() throws Exception {
+    // Create patch set 1 and vote on it
+    String changeId = createChange().getChangeId();
+    ListMultimap<String, ApprovalInfo> votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes).isEmpty();
+    recommend(changeId);
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    List<ApprovalInfo> approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(1);
+    ApprovalInfo approval = approvals.get(0);
+    assertThat(approval._accountId).isEqualTo(admin.id.get());
+    assertThat(approval.email).isEqualTo(admin.email);
+    assertThat(approval.username).isEqualTo(admin.username);
+
+    // Also vote on it with another user
+    requestScopeOperations.setApiUser(user.getId());
+    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+
+    // Patch set 1 has 2 votes on Code-Review
+    requestScopeOperations.setApiUser(admin.getId());
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(2);
+    assertThat(approvals.stream().map(a -> a._accountId))
+        .containsExactlyElementsIn(ImmutableList.of(admin.id.get(), user.id.get()));
+
+    // Create a new patch set which does not have any votes
+    amendChange(changeId);
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes).isEmpty();
+
+    // Votes are still returned for ps 1
+    votes = gApi.changes().id(changeId).revision(1).votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(2);
+  }
+
   private static void assertCherryPickResult(
       ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) throws Exception {
     assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 0208926..178d2bf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -17,7 +17,10 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -33,12 +36,19 @@
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
 import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.IntStream;
 import org.junit.Test;
 
 @NoHttpd
@@ -46,6 +56,7 @@
 public class ListProjectsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ListProjects listProjects;
 
   @Test
   public void listProjects() throws Exception {
@@ -110,6 +121,63 @@
   }
 
   @Test
+  public void listProjectsToOutputStream() throws Exception {
+    int numInitialProjects = gApi.projects().list().get().size();
+    int numTestProjects = 5;
+    List<String> testProjects = createProjects("zzz_testProject", numTestProjects);
+    try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
+
+      listProjects.setStart(numInitialProjects);
+      listProjects.display(displayOut);
+
+      List<String> lines =
+          Splitter.on("\n").omitEmptyStrings().splitToList(new String(displayOut.toByteArray()));
+      assertThat(lines).isEqualTo(testProjects);
+    }
+  }
+
+  @Test
+  public void listProjectsAsJsonMultilineToOutputStream() throws Exception {
+    listProjectsAsJsonToOutputStream(OutputFormat.JSON);
+  }
+
+  @Test
+  public void listProjectsAsJsonCompactToOutputStream() throws Exception {
+    String jsonOutput = listProjectsAsJsonToOutputStream(OutputFormat.JSON_COMPACT).trim();
+    assertThat(jsonOutput).doesNotContain("\n");
+  }
+
+  private String listProjectsAsJsonToOutputStream(OutputFormat jsonFormat) throws Exception {
+    assertThat(jsonFormat.isJson()).isTrue();
+
+    int numInitialProjects = gApi.projects().list().get().size();
+    int numTestProjects = 5;
+    Set<String> testProjects =
+        ImmutableSet.copyOf(createProjects("zzz_testProject", numTestProjects));
+    try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
+
+      listProjects.setStart(numInitialProjects);
+      listProjects.setFormat(jsonFormat);
+      listProjects.display(displayOut);
+
+      String projectsJsonOutput = new String(displayOut.toByteArray());
+
+      Gson gson = jsonFormat.newGson();
+      Set<String> projectsJsonNames = gson.fromJson(projectsJsonOutput, JsonObject.class).keySet();
+      assertThat(projectsJsonNames).isEqualTo(testProjects);
+
+      return projectsJsonOutput;
+    }
+  }
+
+  private List<String> createProjects(String prefix, int numProjects) {
+    return IntStream.range(0, numProjects)
+        .mapToObj(i -> projectOperations.newProject().name(prefix + i).create())
+        .map(Project.NameKey::get)
+        .collect(toList());
+  }
+
+  @Test
   public void listProjectsWithPrefix() throws Exception {
     Project.NameKey someProject = projectOperations.newProject().name("listtest-p1").create();
     Project.NameKey someOtherProject = projectOperations.newProject().name("listtest-p2").create();
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 79eae2a..af9a006 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1379,7 +1379,7 @@
     Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc"));
     Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC"));
     Change change3 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
+    Change change4 = insert(repo, newChangeWithFiles(repo, "Quux.java", "foo"));
 
     assertQuery("extension:java", change4);
     assertQuery("ext:java", change4);
@@ -1387,6 +1387,215 @@
     assertQuery("ext:jAvA", change4);
     assertQuery("ext:.jAvA", change4);
     assertQuery("ext:cc", change3, change2, change1);
+
+    if (getSchemaVersion() >= 56) {
+      // matching changes with files that have no extension is possible
+      assertQuery("ext:\"\"", change4);
+      assertFailingQuery("ext:");
+    }
+  }
+
+  @Test
+  public void byOnlyExtensions() throws Exception {
+    if (getSchemaVersion() < 53) {
+      assertMissingField(ChangeField.ONLY_EXTENSIONS);
+      String unsupportedOperatorMessage =
+          "'onlyextensions' operator is not supported by change index version";
+      assertFailingQuery("onlyextensions:txt,jpg", unsupportedOperatorMessage);
+      assertFailingQuery("onlyexts:txt,jpg", unsupportedOperatorMessage);
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
+    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
+    Change change3 = insert(repo, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
+    Change change4 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change5 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
+    Change change6 = insert(repo, newChangeWithFiles(repo, "foo.txt", "foo"));
+    Change change7 = insert(repo, newChangeWithFiles(repo, "foo"));
+
+    // case doesn't matter
+    assertQuery("onlyextensions:cc,h", change4, change2, change1);
+    assertQuery("onlyextensions:CC,H", change4, change2, change1);
+    assertQuery("onlyextensions:cc,H", change4, change2, change1);
+    assertQuery("onlyextensions:cC,h", change4, change2, change1);
+    assertQuery("onlyextensions:cc", change3);
+    assertQuery("onlyextensions:CC", change3);
+    assertQuery("onlyexts:java", change5);
+    assertQuery("onlyexts:jAvA", change5);
+    assertQuery("onlyexts:.jAvA", change5);
+
+    // order doesn't matter
+    assertQuery("onlyextensions:h,cc", change4, change2, change1);
+    assertQuery("onlyextensions:H,CC", change4, change2, change1);
+
+    // specifying extension with '.' is okay
+    assertQuery("onlyextensions:.cc,.h", change4, change2, change1);
+    assertQuery("onlyextensions:cc,.h", change4, change2, change1);
+    assertQuery("onlyextensions:.cc,h", change4, change2, change1);
+    assertQuery("onlyexts:.java", change5);
+
+    // matching changes without extension is possible
+    assertQuery("onlyexts:txt");
+    assertQuery("onlyexts:txt,", change6);
+    assertQuery("onlyexts:,txt", change6);
+    assertQuery("onlyextensions:\"\"", change7);
+    assertQuery("onlyexts:\"\"", change7);
+    assertQuery("onlyextensions:,", change7);
+    assertQuery("onlyexts:,", change7);
+    assertFailingQuery("onlyextensions:");
+    assertFailingQuery("onlyexts:");
+
+    // inverse queries
+    assertQuery("-onlyextensions:cc,h", change7, change6, change5, change3);
+  }
+
+  @Test
+  public void byFooter() throws Exception {
+    if (getSchemaVersion() < 54) {
+      assertMissingField(ChangeField.FOOTER);
+      assertFailingQuery(
+          "footer:Change-Id=I3d2b978ed455f835d1dad2daa920be0b0ec2ae36",
+          "'footer' operator is not supported by change index version");
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nfoo: baz").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar\nfoo:baz").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    RevCommit commit4 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar=baz").create());
+    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+
+    // create a changes with lines that look like footers, but which are not
+    RevCommit commit5 =
+        repo.parseBody(
+            repo.commit().message("Test\n\nfoo: bar\n\nfoo=bar").insertChangeId().create());
+    Change change5 = insert(repo, newChangeForCommit(repo, commit5));
+    RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
+    insert(repo, newChangeForCommit(repo, commit6));
+
+    // matching by 'key=value' works
+    assertQuery("footer:foo=bar", change3, change1);
+    assertQuery("footer:foo=baz", change3, change2);
+    assertQuery("footer:Change-Id=" + change5.getKey(), change5);
+    assertQuery("footer:foo=bar=baz", change4);
+
+    // case doesn't matter
+    assertQuery("footer:foo=BAR", change3, change1);
+    assertQuery("footer:FOO=bar", change3, change1);
+    assertQuery("footer:fOo=BaZ", change3, change2);
+
+    // verbatim matching of footers works
+    assertQuery("footer:\"foo: bar\"", change3, change1);
+    assertQuery("footer:\"foo: baz\"", change3, change2);
+    assertQuery("footer:\"Change-Id: " + change5.getKey() + "\"", change5);
+    assertQuery("footer:\"foo: bar=baz\"", change4);
+
+    // expect no match because 'a=b: c' of commit6 is not a valid footer (footer key cannot contain
+    // '=')
+    assertQuery("footer:a=b=c");
+    assertQuery("footer:\"a=b: c\"");
+
+    // expect empty result for invalid footers
+    assertQuery("footer:foo");
+    assertQuery("footer:foo=");
+    assertQuery("footer:=foo");
+    assertQuery("footer:=");
+  }
+
+  @Test
+  public void byDirectory() throws Exception {
+    if (getSchemaVersion() < 55) {
+      assertMissingField(ChangeField.DIRECTORY);
+      String unsupportedOperatorMessage =
+          "'directory' operator is not supported by change index version";
+      assertFailingQuery("directory:src/java", unsupportedOperatorMessage);
+      assertFailingQuery("dir:src/java", unsupportedOperatorMessage);
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
+    Change change2 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    Change change3 =
+        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+    Change change4 = insert(repo, newChangeWithFiles(repo, "a.txt"));
+    Change change5 = insert(repo, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+
+    // matching by directory prefix works
+    assertQuery("directory:src", change2, change1);
+    assertQuery("directory:src/java", change2);
+    assertQuery("directory:src/js", change2);
+    assertQuery("directory:documentation/", change3);
+    assertQuery("directory:documentation/training", change3);
+    assertQuery("directory:documentation/training/slides", change3);
+
+    // 'dir' alias works
+    assertQuery("dir:src", change2, change1);
+    assertQuery("dir:src/java", change2);
+
+    // case doesn't matter
+    assertQuery("directory:Documentation/TrAiNiNg/SLIDES", change3);
+
+    // leading and trailing '/' doesn't matter
+    assertQuery("directory:/documentation/training/slides", change3);
+    assertQuery("directory:documentation/training/slides/", change3);
+    assertQuery("directory:/documentation/training/slides/", change3);
+
+    // files do not match as directory
+    assertQuery("directory:src/foo.h");
+    assertQuery("directory:documentation/training/slides/README.txt");
+
+    // root directory matches all changes
+    assertQuery("directory:/", change5, change4, change3, change2, change1);
+    assertQuery("directory:\"\"", change5, change4, change3, change2, change1);
+    assertFailingQuery("directory:");
+
+    // matching single directory segments works
+    assertQuery("directory:java", change2);
+    assertQuery("directory:slides", change3);
+
+    // files do not match as directory segment
+    assertQuery("directory:foo.h");
+
+    // matching any combination of intermediate directory segments works
+    assertQuery("directory:training/slides", change3);
+    assertQuery("directory:b/c", change5);
+    assertQuery("directory:b/c/d", change5);
+    assertQuery("directory:b/c/d/e", change5);
+    assertQuery("directory:c/d", change5);
+    assertQuery("directory:c/d/e", change5);
+    assertQuery("directory:d/e", change5);
+
+    // files do not match as directory segments
+    assertQuery("directory:d/e/foo.txt");
+    assertQuery("directory:e/foo.txt");
+
+    // matching any combination of intermediate directory segments works with leading and trailing
+    // '/'
+    assertQuery("directory:/b/c", change5);
+    assertQuery("directory:/b/c/", change5);
+    assertQuery("directory:b/c/", change5);
+  }
+
+  @Test
+  public void byDirectoryRegex() throws Exception {
+    assume().that(getSchemaVersion()).isAtLeast(55);
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    Change change2 =
+        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+
+    // match by regexp
+    assertQuery("directory:^.*va.*", change1);
+    assertQuery("directory:^documentation/.*/slides", change2);
+    assertQuery("directory:^train.*", change2);
   }
 
   @Test
@@ -3126,12 +3335,19 @@
         .isFalse();
   }
 
-  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+  protected void assertFailingQuery(String query) throws Exception {
+    assertFailingQuery(query, null);
+  }
+
+  protected void assertFailingQuery(String query, @Nullable String expectedMessage)
+      throws Exception {
     try {
       assertQuery(query);
       fail("expected BadRequestException for query '" + query + "'");
     } catch (BadRequestException e) {
-      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+      if (expectedMessage != null) {
+        assertThat(e.getMessage()).isEqualTo(expectedMessage);
+      }
     }
   }
 
diff --git a/plugins/delete-project b/plugins/delete-project
index 83b92fd..93e1145 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 83b92fdba19ff01cd87b08e7ef2ffdfbcffcc3ce
+Subproject commit 93e114582f6c9c4dfceaabc8353565635791336e
diff --git a/plugins/webhooks b/plugins/webhooks
index edf3122..217c8ef 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit edf3122969d445b65feef2174828e115fb1ceedc
+Subproject commit 217c8ef00564e29309b76fdf71bc91c127ec67ec
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
index 97151f2..b5d3dae 100644
--- a/polygerrit-ui/app/.eslintrc.json
+++ b/polygerrit-ui/app/.eslintrc.json
@@ -63,6 +63,7 @@
     "object-shorthand": ["error", "always"],
     "prefer-arrow-callback": "error",
     "prefer-const": "error",
+    "prefer-promise-reject-errors": "off",
     "prefer-spread": "error",
     "quote-props": ["error", "consistent-as-needed"],
     "require-jsdoc": "off",
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
index 77a1e2a..d79bf0d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -307,8 +307,8 @@
         commands.push({
           title,
           command: commandObj[title]
-              .replace('${project}', encodeURI(repo))
-              .replace('${project-base-name}',
+              .replace(/\$\{project\}/gi, encodeURI(repo))
+              .replace(/\$\{project-base-name\}/gi,
               encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
         });
       }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 8aff87e..6699bd1 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -36,9 +36,12 @@
     'conflicts:',
     'deleted:',
     'delta:',
+    'dir:',
+    'directory:',
     'ext:',
     'extension:',
     'file:',
+    'footer:',
     'from:',
     'has:',
     'has:draft',
@@ -66,6 +69,8 @@
     'is:wip',
     'label:',
     'message:',
+    'onlyexts:',
+    'onlyextensions:',
     'owner:',
     'ownerin:',
     'parentproject:',