Merge "Mergeable: do not index the change if the server config for storing mergeability in change index is disabled"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 9a40b27..185fa07 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -1370,10 +1370,11 @@
 [[capability_createProject]]
 === Create Project
 
-Allow project creation.  This capability allows the granted group to
-either link:cmd-create-project.html[create new git projects via ssh]
-or via the web UI.
+Allow project creation.
 
+This capability allows the granted group to create projects via the web UI, via
+link:rest-api-projects.html#create-project][REST] and via
+link:cmd-create-project.html[SSH].
 
 [[capability_emailReviewers]]
 === Email Reviewers
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 73668d7..780d3ec 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -88,7 +88,7 @@
 
 image::images/user-review-ui-change-metadata.png[width=600, link="images/user-review-ui-change-metadata.png"]
 
-- [[owner]]Owner/Uploader/Author/Committer
+- [[owner]]Owner/Uploader/Author/Committer:
 +
 Owner is the person who created the change
 +
@@ -170,11 +170,11 @@
 The SHA of the commit corresponding to the merged change on the destination
 branch.
 
-- [[revert-created-as]]Revert (Created|Submitted) As
+- [[revert-created-as]]Revert (Created|Submitted) As:
 +
 Points to the revert change, if one was created.
 
-- [[cherry-pick-of]]Cherry-pick of
+- [[cherry-pick-of]]Cherry-pick of:
 +
 If the change was created as cherry-pick of some other change to a different
 branch, points to the original change.
@@ -207,7 +207,7 @@
 link:config-submit-requirements.html[Submit Requirement Configuration] page.
 
 [[actions]]
-=== Actions:
+=== Actions
 Actions buttons are at the top right and in the overflow menu.
 Depending on the change state and the permissions of the user, different
 actions are available on the change:
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 565c491..e12c27c 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -43,6 +43,17 @@
 For more predictable results, use explicit search operators as described
 in the following section.
 
+[IMPORTANT]
+--
+The change search API is backed by a secondary index and might sometimes return
+stale results if the re-indexing operation failed for a change update.
+
+Please also note that changes are not re-indexed if the project configuration
+is updated with newly added or modified
+link:config-submit-requirements.html[submit requirements].
+--
+
+
 [[search-operators]]
 == Search Operators
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index c1029be..62ad7c4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -139,9 +140,10 @@
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
       Instant now = TimeUtil.now();
       IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
+      PersonIdent author = getAuthorIdent(now, changeCreation);
+      PersonIdent committer = getCommitterIdent(now, changeCreation);
       ObjectId commitId =
-          createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
+          createCommit(repository, revWalk, objectInserter, changeCreation, author, committer);
 
       String refName = RefNames.fullName(changeCreation.branch());
       ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
@@ -189,6 +191,30 @@
     return getArbitraryUser();
   }
 
+  private PersonIdent getAuthorIdent(Instant when, TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.authorIdent().isPresent()) {
+      return new PersonIdent(changeCreation.authorIdent().get(), when);
+    }
+
+    return (changeCreation.author().isPresent()
+            ? userFactory.create(changeCreation.author().get())
+            : getChangeOwner(changeCreation))
+        .newCommitterIdent(when, serverIdent.getZoneId());
+  }
+
+  private PersonIdent getCommitterIdent(Instant when, TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.committerIdent().isPresent()) {
+      return new PersonIdent(changeCreation.committerIdent().get(), when);
+    }
+
+    return (changeCreation.committer().isPresent()
+            ? userFactory.create(changeCreation.committer().get())
+            : getChangeOwner(changeCreation))
+        .newCommitterIdent(when, serverIdent.getZoneId());
+  }
+
   private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
     ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
     checkState(
@@ -202,15 +228,15 @@
       RevWalk revWalk,
       ObjectInserter objectInserter,
       TestChangeCreation changeCreation,
-      PersonIdent authorAndCommitter)
+      PersonIdent author,
+      PersonIdent committer)
       throws IOException, BadRequestException {
     ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation);
     TreeCreator treeCreator =
         getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy());
     ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications());
     String commitMessage = correctCommitMessage(changeCreation.commitMessage());
-    return createCommit(
-        objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
+    return createCommit(objectInserter, tree, parentCommits, author, committer, commitMessage);
   }
 
   private ImmutableList<ObjectId> getParentCommits(
@@ -432,17 +458,28 @@
           ObjectInserter objectInserter = repository.newObjectInserter();
           RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
         Instant now = TimeUtil.now();
+        PersonIdent authorIdent = getAuthorIdent(now, patchsetCreation);
+        PersonIdent committerIdent = getCommitterIdent(now, patchsetCreation);
         ObjectId newPatchsetCommit =
             createPatchsetCommit(
-                repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
+                repository,
+                revWalk,
+                objectInserter,
+                changeNotes,
+                patchsetCreation,
+                authorIdent,
+                committerIdent,
+                now);
 
         PatchSet.Id patchsetId =
             ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
         PatchSetInserter patchSetInserter =
             getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
 
-        IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
-        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+        Account.Id uploaderId =
+            patchsetCreation.uploader().orElse(changeNotes.getChange().getOwner());
+        IdentifiedUser uploader = userFactory.create(uploaderId);
+        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, uploader, now)) {
           batchUpdate.setRepository(repository, revWalk, objectInserter);
           batchUpdate.addOp(changeId, patchSetInserter);
           batchUpdate.execute();
@@ -451,12 +488,44 @@
       }
     }
 
+    @Nullable
+    private PersonIdent getAuthorIdent(Instant when, TestPatchsetCreation patchsetCreation) {
+      if (patchsetCreation.authorIdent().isPresent()) {
+        return new PersonIdent(patchsetCreation.authorIdent().get(), when);
+      }
+
+      if (patchsetCreation.author().isPresent()) {
+        return userFactory
+            .create(patchsetCreation.author().get())
+            .newCommitterIdent(when, serverIdent.getZoneId());
+      }
+
+      return null;
+    }
+
+    @Nullable
+    private PersonIdent getCommitterIdent(Instant when, TestPatchsetCreation patchsetCreation) {
+      if (patchsetCreation.committerIdent().isPresent()) {
+        return new PersonIdent(patchsetCreation.committerIdent().get(), when);
+      }
+
+      if (patchsetCreation.committer().isPresent()) {
+        return userFactory
+            .create(patchsetCreation.committer().get())
+            .newCommitterIdent(when, serverIdent.getZoneId());
+      }
+
+      return null;
+    }
+
     private ObjectId createPatchsetCommit(
         Repository repository,
         RevWalk revWalk,
         ObjectInserter objectInserter,
         ChangeNotes changeNotes,
         TestPatchsetCreation patchsetCreation,
+        @Nullable PersonIdent author,
+        @Nullable PersonIdent committer,
         Instant now)
         throws IOException, BadRequestException {
       ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
@@ -472,9 +541,13 @@
               changeNotes.getChange().getKey().get(),
               patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage));
 
-      PersonIdent author = getAuthor(oldPatchsetCommit);
-      PersonIdent committer = getCommitter(oldPatchsetCommit, now);
-      return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
+      return createCommit(
+          objectInserter,
+          tree,
+          parentCommitIds,
+          Optional.ofNullable(author).orElse(getAuthor(oldPatchsetCommit)),
+          Optional.ofNullable(committer).orElse(getCommitter(oldPatchsetCommit, now)),
+          commitMessage);
     }
 
     private String correctCommitMessage(String oldChangeId, String desiredCommitMessage)
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index f01a138..a0746e2 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -24,6 +26,7 @@
 import com.google.gerrit.server.edit.tree.TreeModification;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.merge.MergeStrategy;
 
 /** Initial attributes of the change. If not provided, arbitrary values will be used. */
@@ -35,6 +38,14 @@
 
   public abstract Optional<Account.Id> owner();
 
+  public abstract Optional<Account.Id> author();
+
+  public abstract Optional<PersonIdent> authorIdent();
+
+  public abstract Optional<Account.Id> committer();
+
+  public abstract Optional<PersonIdent> committerIdent();
+
   public abstract Optional<String> topic();
 
   public abstract ImmutableMap<String, Short> approvals();
@@ -69,9 +80,65 @@
      */
     public abstract Builder branch(String branch);
 
-    /** The change owner. Must be an existing user account. */
+    /**
+     * The change owner.
+     *
+     * <p>Must be an existing user account.
+     */
     public abstract Builder owner(Account.Id owner);
 
+    /**
+     * The author of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #authorIdent()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder author(Account.Id author);
+
+    /**
+     * The author ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #author()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder authorIdent(PersonIdent authorIdent);
+
+    public abstract Optional<Account.Id> author();
+
+    public abstract Optional<PersonIdent> authorIdent();
+
+    /**
+     * The committer of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #committerIdent()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committer(Account.Id committer);
+
+    /**
+     * The committer ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #committer()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committerIdent(PersonIdent committerIdent);
+
+    public abstract Optional<Account.Id> committer();
+
+    public abstract Optional<PersonIdent> committerIdent();
+
     /** The topic to add this change to. */
     public abstract Builder topic(String topic);
 
@@ -156,13 +223,23 @@
 
     abstract TestChangeCreation autoBuild();
 
+    public TestChangeCreation build() {
+      checkState(
+          author().isEmpty() || authorIdent().isEmpty(),
+          "author and authorIdent cannot be set together");
+      checkState(
+          committer().isEmpty() || committerIdent().isEmpty(),
+          "committer and committerIdent cannot be set together");
+      return autoBuild();
+    }
+
     /**
      * Creates the change.
      *
      * @return the {@code Change.Id} of the created change
      */
     public Change.Id create() {
-      TestChangeCreation changeUpdate = autoBuild();
+      TestChangeCreation changeUpdate = build();
       return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
     }
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index 22a4da6..f8ca977 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -14,17 +14,31 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /** Initial attributes of the patchset. If not provided, arbitrary values will be used. */
 @AutoValue
 public abstract class TestPatchsetCreation {
 
+  public abstract Optional<Account.Id> uploader();
+
+  public abstract Optional<Account.Id> author();
+
+  public abstract Optional<PersonIdent> authorIdent();
+
+  public abstract Optional<Account.Id> committer();
+
+  public abstract Optional<PersonIdent> committerIdent();
+
   public abstract Optional<String> commitMessage();
 
   public abstract ImmutableList<TreeModification> treeModifications();
@@ -40,6 +54,66 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    /**
+     * The uploader for the new patch set.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>If not set the new patch set is uploaded by the change owner.
+     */
+    public abstract Builder uploader(Account.Id uploader);
+
+    /**
+     * The author of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #authorIdent()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder author(Account.Id author);
+
+    /**
+     * The author ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #author()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder authorIdent(PersonIdent authorIdent);
+
+    public abstract Optional<Account.Id> author();
+
+    public abstract Optional<PersonIdent> authorIdent();
+
+    /**
+     * The committer of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #committerIdent()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committer(Account.Id committer);
+
+    /**
+     * The committer ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #committer()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committerIdent(PersonIdent committerIdent);
+
+    public abstract Optional<Account.Id> committer();
+
+    public abstract Optional<PersonIdent> committerIdent();
 
     public abstract Builder commitMessage(String commitMessage);
 
@@ -92,13 +166,23 @@
 
     abstract TestPatchsetCreation autoBuild();
 
+    public TestPatchsetCreation build() {
+      checkState(
+          author().isEmpty() || authorIdent().isEmpty(),
+          "author and authorIdent cannot be set together");
+      checkState(
+          committer().isEmpty() || committerIdent().isEmpty(),
+          "committer and committerIdent cannot be set together");
+      return autoBuild();
+    }
+
     /**
      * Creates the patchset.
      *
      * @return the {@code PatchSet.Id} of the created patchset
      */
     public PatchSet.Id create() {
-      TestPatchsetCreation patchsetCreation = autoBuild();
+      TestPatchsetCreation patchsetCreation = build();
       return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
     }
   }
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index df2c5cb..7293f35 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
@@ -310,6 +311,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
     modules.add(new PluginApiModule());
     modules.add(new SearchingChangeCacheImplModule());
     modules.add(new InternalAccountDirectoryModule());
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 129d961..961bf9b 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -79,7 +79,6 @@
           "/dashboard/*",
           "/groups/self",
           "/settings/*",
-          "/topic/*",
           "/Documentation/q/*");
 
   /**
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index f2aafcf9..870d827 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -156,4 +156,14 @@
   default boolean isEnabled() {
     return true;
   }
+
+  /**
+   * Rewriter that should be invoked on queries to this index.
+   *
+   * <p>The default implementation does not do anything. Should be overridden by implementation, if
+   * needed.
+   */
+  default IndexRewriter<V> getIndexRewriter() {
+    return (in, opts) -> in;
+  }
 }
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index f237006..1c8bbc3 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -268,7 +268,9 @@
                 limit,
                 getRequestedFields());
         logger.atFine().log("Query options: %s", opts);
-        Predicate<T> pred = rewriter.rewrite(q, opts);
+        // Apply index-specific rewrite first
+        Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
+        pred = rewriter.rewrite(pred, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 0342fe5..845cc9a 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
@@ -448,6 +449,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
     modules.add(new PluginApiModule());
 
     modules.add(new SearchingChangeCacheImplModule(replica));
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 5bffce7..cae7ca6 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.LibModuleLoader;
@@ -83,19 +84,19 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.query.change.DistinctVotersPredicate;
-import com.google.gerrit.server.query.change.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -127,6 +128,7 @@
     modules.add(PatchListCacheImpl.module());
     modules.add(new DefaultUrlFormatterModule());
     modules.add(DiffOperationsImpl.module());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
 
     // There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
     //  listener().to(SomeClassImplementingLifecycleListener.class);
diff --git a/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java b/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java
new file mode 100644
index 0000000..bef276a
--- /dev/null
+++ b/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2023 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;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+@Singleton
+public class DefaultRefLogIdentityProvider implements RefLogIdentityProvider {
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(RefLogIdentityProvider.class).to(DefaultRefLogIdentityProvider.class);
+    }
+  }
+
+  private final String anonymousCowardName;
+  private final Boolean enablePeerIPInReflogRecord;
+
+  @Inject
+  DefaultRefLogIdentityProvider(
+      @AnonymousCowardName String anonymousCowardName,
+      @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord) {
+    this.anonymousCowardName = anonymousCowardName;
+    this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
+  }
+
+  @Override
+  public PersonIdent newRefLogIdent(IdentifiedUser user, Instant when, ZoneId zoneId) {
+    Account account = user.getAccount();
+
+    String name = account.fullName();
+    if (name == null || name.isEmpty()) {
+      name = account.preferredEmail();
+    }
+    if (name == null || name.isEmpty()) {
+      name = anonymousCowardName;
+    }
+
+    String email;
+    if (enablePeerIPInReflogRecord) {
+      email = constructMailAddress(user, guessHost(user));
+    } else {
+      email =
+          Strings.isNullOrEmpty(account.preferredEmail())
+              ? constructMailAddress(user, "unknown")
+              : account.preferredEmail();
+    }
+
+    return new PersonIdent(name, email, when, zoneId);
+  }
+
+  private String constructMailAddress(IdentifiedUser user, String host) {
+    return user.getUserName().orElse("")
+        + "|account-"
+        + user.getAccountId().toString()
+        + "@"
+        + host;
+  }
+
+  private String guessHost(IdentifiedUser user) {
+    String host = null;
+    SocketAddress remotePeer = user.getRemotePeer();
+    if (remotePeer instanceof InetSocketAddress) {
+      InetSocketAddress sa = (InetSocketAddress) remotePeer;
+      InetAddress in = sa.getAddress();
+      host = in != null ? in.getHostAddress() : sa.getHostName();
+    }
+    if (Strings.isNullOrEmpty(host)) {
+      return "unknown";
+    }
+    return host;
+  }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 65a81f7..36d7888 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -19,7 +19,6 @@
 import static com.google.common.flogger.LazyArgs.lazy;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -44,8 +43,6 @@
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.util.Providers;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
 import java.net.MalformedURLException;
 import java.net.SocketAddress;
 import java.net.URL;
@@ -66,6 +63,7 @@
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
+    private final RefLogIdentityProvider refLogIdentityProvider;
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
@@ -76,6 +74,7 @@
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
+        RefLogIdentityProvider refLogIdentityProvider,
         @CanonicalWebUrl Provider<String> canonicalUrl,
         @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord,
         AccountCache accountCache,
@@ -83,6 +82,7 @@
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
+      this.refLogIdentityProvider = refLogIdentityProvider;
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
@@ -94,36 +94,37 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
           enablePeerIPInReflogRecord,
           Providers.of(null),
           state,
-          null);
+          /* realUser= */ null);
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return create(null, id);
+      return create(/* remotePeer= */ null, id);
     }
 
     @VisibleForTesting
     @UsedAt(UsedAt.Project.GOOGLE)
     public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
-      return runAs(null, id, null, properties);
+      return runAs(/* remotePeer= */ null, id, /* caller= */ null, properties);
     }
 
-    public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return runAs(remotePeer, id, null);
+    public IdentifiedUser create(@Nullable SocketAddress remotePeer, Account.Id id) {
+      return runAs(remotePeer, id, /* caller= */ null);
     }
 
     public IdentifiedUser runAs(
-        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+        @Nullable SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
       return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
     }
 
     private IdentifiedUser runAs(
-        SocketAddress remotePeer,
+        @Nullable SocketAddress remotePeer,
         Account.Id id,
         @Nullable CurrentUser caller,
         PropertyMap properties) {
@@ -131,6 +132,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -153,6 +155,7 @@
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
+    private final RefLogIdentityProvider refLogIdentityProvider;
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
@@ -164,6 +167,7 @@
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
+        RefLogIdentityProvider refLogIdentityProvider,
         @CanonicalWebUrl Provider<String> canonicalUrl,
         AccountCache accountCache,
         GroupBackend groupBackend,
@@ -172,6 +176,7 @@
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
+      this.refLogIdentityProvider = refLogIdentityProvider;
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
@@ -188,6 +193,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -203,6 +209,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -224,6 +231,7 @@
   private final Realm realm;
   private final GroupBackend groupBackend;
   private final String anonymousCowardName;
+  private final RefLogIdentityProvider refLogIdentityProvider;
   private final Boolean enablePeerIPInReflogRecord;
   private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
   private final CurrentUser realUser; // Must be final since cached properties depend on it.
@@ -235,22 +243,25 @@
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
+  private PersonIdent refLogIdent;
 
   private IdentifiedUser(
       AuthConfig authConfig,
       Realm realm,
       String anonymousCowardName,
+      RefLogIdentityProvider refLogIdentityProvider,
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
     this(
         authConfig,
         realm,
         anonymousCowardName,
+        refLogIdentityProvider,
         canonicalUrl,
         accountCache,
         groupBackend,
@@ -266,11 +277,12 @@
       AuthConfig authConfig,
       Realm realm,
       String anonymousCowardName,
+      RefLogIdentityProvider refLogIdentityProvider,
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
       @Nullable CurrentUser realUser,
       PropertyMap properties) {
@@ -281,6 +293,7 @@
     this.authConfig = authConfig;
     this.realm = realm;
     this.anonymousCowardName = anonymousCowardName;
+    this.refLogIdentityProvider = refLogIdentityProvider;
     this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
     this.remotePeerProvider = remotePeerProvider;
     this.accountId = id;
@@ -426,36 +439,27 @@
     return getAccountId();
   }
 
+  @Nullable
+  public SocketAddress getRemotePeer() {
+    try {
+      return remotePeerProvider.get();
+    } catch (OutOfScopeException | ProvisionException e) {
+      return null;
+    }
+  }
+
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
+    return refLogIdentityProvider.newRefLogIdent(this);
   }
 
   public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
-    final Account ua = getAccount();
-
-    String name = ua.fullName();
-    if (name == null || name.isEmpty()) {
-      name = ua.preferredEmail();
+    if (refLogIdent != null) {
+      refLogIdent =
+          new PersonIdent(refLogIdent.getName(), refLogIdent.getEmailAddress(), when, zoneId);
+      return refLogIdent;
     }
-    if (name == null || name.isEmpty()) {
-      name = anonymousCowardName;
-    }
-
-    String user;
-    if (enablePeerIPInReflogRecord) {
-      user = constructMailAddress(ua, guessHost());
-    } else {
-      user =
-          Strings.isNullOrEmpty(ua.preferredEmail())
-              ? constructMailAddress(ua, "unknown")
-              : ua.preferredEmail();
-    }
-
-    return new PersonIdent(name, user, when, zoneId);
-  }
-
-  private String constructMailAddress(Account ua, String host) {
-    return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
+    refLogIdent = refLogIdentityProvider.newRefLogIdent(this, when, zoneId);
+    return refLogIdent;
   }
 
   public PersonIdent newCommitterIdent(PersonIdent ident) {
@@ -533,6 +537,7 @@
         authConfig,
         realm,
         anonymousCowardName,
+        refLogIdentityProvider,
         Providers.of(canonicalUrl.get()),
         accountCache,
         groupBackend,
@@ -546,23 +551,4 @@
   public boolean hasSameAccountId(CurrentUser other) {
     return getAccountId().get() == other.getAccountId().get();
   }
-
-  private String guessHost() {
-    String host = null;
-    SocketAddress remotePeer = null;
-    try {
-      remotePeer = remotePeerProvider.get();
-    } catch (OutOfScopeException | ProvisionException e) {
-      // Leave null.
-    }
-    if (remotePeer instanceof InetSocketAddress) {
-      InetSocketAddress sa = (InetSocketAddress) remotePeer;
-      InetAddress in = sa.getAddress();
-      host = in != null ? in.getHostAddress() : sa.getHostName();
-    }
-    if (Strings.isNullOrEmpty(host)) {
-      return "unknown";
-    }
-    return host;
-  }
 }
diff --git a/java/com/google/gerrit/server/RefLogIdentityProvider.java b/java/com/google/gerrit/server/RefLogIdentityProvider.java
new file mode 100644
index 0000000..2a5d2b0
--- /dev/null
+++ b/java/com/google/gerrit/server/RefLogIdentityProvider.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2023 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;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * Extension point that allows to control which identity should be recorded in the reflog for ref
+ * updates done by a user or done on behalf of a user.
+ */
+public interface RefLogIdentityProvider {
+  /**
+   * Creates a {@link PersonIdent} for the given user that should be used as the user identity in
+   * the reflog for ref updates done by this user or done on behalf of this user.
+   *
+   * <p>The returned {@link PersonIdent} is created with the current timestamp and the system
+   * default timezone.
+   *
+   * @param user the user for which a reflog identity should be created
+   */
+  default PersonIdent newRefLogIdent(IdentifiedUser user) {
+    return newRefLogIdent(user, Instant.now(), ZoneId.systemDefault());
+  }
+
+  /**
+   * Creates a {@link PersonIdent} for the given user that should be used as the user identity in
+   * the reflog for ref updates done by this user or done on behalf of this user.
+   *
+   * @param user the user for which a reflog identity should be created
+   * @param when the timestamp that should be used to create the {@link PersonIdent}
+   * @param zoneId the zone ID identifying the timezone that should be used to create the {@link
+   *     PersonIdent}
+   */
+  PersonIdent newRefLogIdent(IdentifiedUser user, Instant when, ZoneId zoneId);
+}
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 389b292..d2417d7 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -211,7 +211,7 @@
         return searchedAsUser.asIdentifiedUser();
       }
       return userFactory.runAs(
-          null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
+          /* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
     }
 
     @VisibleForTesting
@@ -790,7 +790,7 @@
 
     @Override
     public Predicate<AccountState> get() {
-      return accountControlFactory.get(asUser.asIdentifiedUser())::canSee;
+      return accountControlFactory.get(asUser)::canSee;
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index dcbd1ae..8acc925 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -37,7 +35,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
@@ -70,30 +67,28 @@
     this.rebaseFactory = rebaseFactory;
   }
 
-  public static void verifyRebasePreconditions(
-      ProjectCache projectCache, PatchSetUtil patchSetUtil, RevWalk rw, RevisionResource rsrc)
-      throws ResourceConflictException, IOException, AuthException, PermissionBackendException {
+  /**
+   * Checks whether the given change fulfills all preconditions to be rebased.
+   *
+   * <p>This method does not check whether the calling user is allowed to rebase the change.
+   */
+  public void verifyRebasePreconditions(RevWalk rw, ChangeNotes changeNotes, PatchSet patchSet)
+      throws ResourceConflictException, IOException {
     // Not allowed to rebase if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
+    psUtil.checkPatchSetNotLocked(changeNotes);
 
-    rsrc.permissions().check(ChangePermission.REBASE);
-    projectCache
-        .get(rsrc.getProject())
-        .orElseThrow(illegalState(rsrc.getProject()))
-        .checkStatePermitsWrite();
-
-    if (!rsrc.getChange().isNew()) {
+    Change change = changeNotes.getChange();
+    if (!change.isNew()) {
       throw new ResourceConflictException(
-          String.format(
-              "Change %s is %s", rsrc.getChange().getId(), ChangeUtil.status(rsrc.getChange())));
-    } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
+          String.format("Change %s is %s", change.getId(), ChangeUtil.status(change)));
+    }
+
+    if (!hasOneParent(rw, patchSet)) {
       throw new ResourceConflictException(
           String.format(
               "Error rebasing %s. Cannot rebase %s",
-              rsrc.getChange().getId(),
-              countParents(rw, rsrc.getPatchSet()) > 1
-                  ? "merge commits"
-                  : "commit with no ancestor"));
+              change.getId(),
+              countParents(rw, patchSet) > 1 ? "merge commits" : "commit with no ancestor"));
     }
   }
 
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index 8fbb259..0df7729 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -225,6 +225,11 @@
 
   private static Optional<Range> getStartAndEndLines(ContextInput comment) {
     if (comment.range() != null) {
+      if (comment.range().endLine < comment.range().startLine) {
+        // Seems like comments, created in reply to robot comments sometimes have invalid ranges
+        // Fix here, otherwise the range is invalid and we throw an error later on.
+        return Optional.of(Range.create(comment.range().startLine, comment.range().startLine + 1));
+      }
       return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
     } else if (comment.lineNumber() > 0) {
       return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index f442500..f194434 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -194,14 +194,11 @@
 import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.query.change.DistinctVotersPredicate;
-import com.google.gerrit.server.query.change.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -217,6 +214,9 @@
 import com.google.gerrit.server.submit.MergeSuperSetComputation;
 import com.google.gerrit.server.submit.SubmitStrategy;
 import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.IdGenerator;
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 5a4580c..32ec401 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -20,14 +20,9 @@
 public class ExperimentFeaturesConstants {
 
   /** Features that are known experiments and can be referenced in the code. */
-  public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
-
-  public static String UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
-
   public static String GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION =
       "GerritBackendFeature__attach_nonce_to_documentation";
 
   /** Features, enabled by default in the current release. */
-  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
-      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
+  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 6f7d761..e36ce7b 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -216,7 +216,10 @@
     public void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException {
       if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted");
+        throw new AuthException(
+            perm.describeForException()
+                + " not permitted"
+                + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
       }
     }
 
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 63b0378..6ceed3e 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -16,7 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
 
 public enum ChangePermission implements ChangePermissionOrLabel {
   READ,
@@ -53,24 +55,40 @@
    * <p>Before checking this permission, the caller should first verify the current patch set of the
    * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
    */
-  REBASE,
+  REBASE(
+      /* description= */ null,
+      /* hint= */ "change owners and users with the 'Submit' or 'Rebase' permission can rebase"
+          + " if they have the 'Push' permission"),
   REVERT,
   SUBMIT,
   SUBMIT_AS("submit on behalf of other users"),
   TOGGLE_WORK_IN_PROGRESS_STATE;
 
   private final String description;
+  private final String hint;
 
   ChangePermission() {
     this.description = null;
+    this.hint = null;
   }
 
   ChangePermission(String description) {
     this.description = requireNonNull(description);
+    this.hint = null;
+  }
+
+  ChangePermission(@Nullable String description, String hint) {
+    this.description = description;
+    this.hint = requireNonNull(hint);
   }
 
   @Override
   public String describeForException() {
     return description != null ? description : GerritPermission.describeEnumValue(this);
   }
+
+  @Override
+  public Optional<String> hintForException() {
+    return Optional.ofNullable(hint);
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
index f59ba02..9254158 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -15,6 +15,17 @@
 package com.google.gerrit.server.permissions;
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
 
 /** A {@link ChangePermission} or a {@link AbstractLabelPermission}. */
-public interface ChangePermissionOrLabel extends GerritPermission {}
+public interface ChangePermissionOrLabel extends GerritPermission {
+  /**
+   * A hint that explains under which conditions this permission is permitted.
+   *
+   * <p>This is useful for permissions that are not directly assigned but are indirectly permitted
+   * by the user having other permissions or being the change owner.
+   */
+  default Optional<String> hintForException() {
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 0afaa3f..6498d1b 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -76,7 +76,6 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -301,20 +300,19 @@
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
-      Stream<AccountGroup.UUID> configuredRelevantGroups =
-          Arrays.stream(config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
-              .map(AccountGroup::uuid);
-
-      Stream<AccountGroup.UUID> guessedRelevantGroups =
-          inMemoryProjectCache.asMap().values().stream()
-              .filter(Objects::nonNull)
-              .flatMap(p -> p.getAllGroupUUIDs().stream())
-              // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
-              // against them just in case there is a bug or corner case.
-              .filter(id -> id != null && id.get() != null);
-
       Set<AccountGroup.UUID> relevantGroupUuids =
-          Streams.concat(configuredRelevantGroups, guessedRelevantGroups).collect(toSet());
+          Streams.concat(
+                  Arrays.stream(
+                          config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
+                      .map(AccountGroup::uuid),
+                  all().stream()
+                      .map(n -> inMemoryProjectCache.getIfPresent(n))
+                      .filter(Objects::nonNull)
+                      .flatMap(p -> p.getAllGroupUUIDs().stream())
+                      // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+                      // against them just in case there is a bug or corner case.
+                      .filter(id -> id != null && id.get() != null))
+              .collect(toSet());
       logger.atFine().log("relevant group UUIDs: %s", relevantGroupUuids);
       return relevantGroupUuids;
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 714516c..ca18ab2 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -551,14 +551,14 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
         ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
         ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
@@ -641,7 +641,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
       return new IsAttentionPredicate();
     }
 
@@ -684,7 +684,7 @@
     }
 
     if ("uploader".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
+      checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
       return ChangePredicates.uploader(self());
     }
 
@@ -707,7 +707,7 @@
     }
 
     if ("merge".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.MERGE_SPEC, "is:merge");
+      checkOperatorAvailable(ChangeField.MERGE_SPEC, "is:merge");
       return new BooleanPredicate(ChangeField.MERGE_SPEC);
     }
 
@@ -716,7 +716,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
       return new IsAttentionPredicate();
     }
 
@@ -729,7 +729,7 @@
     }
 
     if ("pure-revert".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
+      checkOperatorAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
       return ChangePredicates.pureRevert("1");
     }
 
@@ -745,12 +745,12 @@
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
       }
-      checkFieldAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
+      checkOperatorAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
       return new IsSubmittablePredicate();
     }
 
     if ("started".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.STARTED_SPEC, "is:started");
+      checkOperatorAvailable(ChangeField.STARTED_SPEC, "is:started");
       return new BooleanPredicate(ChangeField.STARTED_SPEC);
     }
 
@@ -759,7 +759,7 @@
     }
 
     if ("cherrypick".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
+      checkOperatorAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
       return new BooleanPredicate(ChangeField.CHERRY_PICK_SPEC);
     }
 
@@ -894,7 +894,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
+    checkOperatorAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
     return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
@@ -904,7 +904,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
+    checkOperatorAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
     return ChangePredicates.prefixHashtag(hashtag);
   }
 
@@ -930,7 +930,7 @@
       return ChangePredicates.exactTopic(name);
     }
 
-    checkFieldAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
+    checkOperatorAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
     return ChangePredicates.prefixTopic(name);
   }
 
@@ -996,7 +996,7 @@
 
   @Operator
   public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
-    checkFieldAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+    checkOperatorAvailable(ChangeField.FOOTER_NAME, "hasfooter");
     return ChangePredicates.hasFooter(footerName);
   }
 
@@ -1147,7 +1147,9 @@
   @Operator
   public Predicate<ChangeData> message(String text) throws QueryParseException {
     if (text.startsWith("^")) {
-      checkFieldAvailable(ChangeField.COMMIT_MESSAGE_EXACT, "messageexact");
+      checkFieldAvailable(
+          ChangeField.COMMIT_MESSAGE_EXACT,
+          "'message' operator with regular expression is not supported on this gerrit host");
       return new RegexMessagePredicate(text);
     }
     return ChangePredicates.message(text);
@@ -1155,13 +1157,14 @@
 
   @Operator
   public Predicate<ChangeData> subject(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
+    checkOperatorAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
     return ChangePredicates.subject(value);
   }
 
   @Operator
   public Predicate<ChangeData> prefixsubject(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.PREFIX_SUBJECT_SPEC, ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+    checkOperatorAvailable(
+        ChangeField.PREFIX_SUBJECT_SPEC, ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
     return ChangePredicates.prefixSubject(value);
   }
 
@@ -1249,7 +1252,7 @@
   @Operator
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.UPLOADER_SPEC, "uploader");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploader");
     return uploader(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1264,7 +1267,7 @@
   @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
+    checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
     return attention(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1309,7 +1312,7 @@
 
   @Operator
   public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
-    checkFieldAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
 
     GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
     if (g == null) {
@@ -1575,8 +1578,8 @@
 
   @Operator
   public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
     if (Ints.tryParse(value) != null) {
       return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
     }
@@ -1648,11 +1651,16 @@
     return Predicate.or(predicates);
   }
 
-  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String operator)
+  private void checkOperatorAvailable(SchemaField<ChangeData, ?> field, String operator)
+      throws QueryParseException {
+    checkFieldAvailable(
+        field, String.format("'%s' operator is not supported on this gerrit host", operator));
+  }
+
+  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String errorMessage)
       throws QueryParseException {
     if (!args.index.getSchema().hasField(field)) {
-      throw new QueryParseException(
-          String.format("'%s' operator is not supported on this gerrit host", operator));
+      throw new QueryParseException(errorMessage);
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index ccd645b..3edad69 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -103,6 +104,7 @@
     return query(ChangePredicates.idStr(id));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
@@ -115,15 +117,6 @@
     return query(byBranchKeyPred(branch, key));
   }
 
-  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
-    return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
-  }
-
-  public static Predicate<ChangeData> byBranchKeyOpenPred(
-      Project.NameKey project, String branch, Change.Key key) {
-    return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
-  }
-
   private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
     return and(ref(branch), project(branch.project()), change(key));
   }
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index 3f4c158..698628e 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -19,8 +19,12 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.query.FileEditsPredicate;
-import com.google.gerrit.server.query.FileEditsPredicate.FileEditsArgs;
+import com.google.gerrit.server.submitrequirement.predicate.ConstantPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate.FileEditsArgs;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
+import com.google.gerrit.server.submitrequirement.predicate.RegexAuthorEmailPredicate;
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.Locale;
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index e35ffdb..4b16143 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -11,91 +11,33 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-
 package com.google.gerrit.server.restapi.account;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangePredicates;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.restapi.change.CommentJson;
-import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
 
 @Singleton
 public class DeleteDraftComments
     implements RestModifyView<AccountResource, DeleteDraftCommentsInput> {
-
   private final Provider<CurrentUser> userProvider;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeQueryBuilder queryBuilder;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeJson.Factory changeJsonFactory;
-  private final Provider<CommentJson> commentJsonProvider;
-  private final CommentsUtil commentsUtil;
-  private final PatchSetUtil psUtil;
+  private final DeleteDraftCommentsUtil deleteDraftCommentsUtil;
 
   @Inject
   DeleteDraftComments(
-      Provider<CurrentUser> userProvider,
-      BatchUpdate.Factory batchUpdateFactory,
-      ChangeQueryBuilder queryBuilder,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeData.Factory changeDataFactory,
-      ChangeJson.Factory changeJsonFactory,
-      Provider<CommentJson> commentJsonProvider,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil) {
+      Provider<CurrentUser> userProvider, DeleteDraftCommentsUtil deleteDraftCommentsUtil) {
     this.userProvider = userProvider;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilder = queryBuilder;
-    this.queryProvider = queryProvider;
-    this.changeDataFactory = changeDataFactory;
-    this.changeJsonFactory = changeJsonFactory;
-    this.commentJsonProvider = commentJsonProvider;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
+    this.deleteDraftCommentsUtil = deleteDraftCommentsUtil;
   }
 
   @Override
@@ -114,82 +56,6 @@
       // hasSameAccountId check.)
       throw new AuthException("Cannot delete drafts of other user");
     }
-
-    HumanCommentFormatter humanCommentFormatter =
-        commentJsonProvider.get().newHumanCommentFormatter();
-    Account.Id accountId = rsrc.getUser().getAccountId();
-    Instant now = TimeUtil.now();
-    Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
-    List<Op> ops = new ArrayList<>();
-    for (ChangeData cd :
-        queryProvider
-            .get()
-            // Don't attempt to mutate any changes the user can't currently see.
-            .enforceVisibility(true)
-            .query(predicate(accountId, input))) {
-      BatchUpdate update =
-          updates.computeIfAbsent(
-              cd.project(), p -> batchUpdateFactory.create(p, rsrc.getUser(), now));
-      Op op = new Op(humanCommentFormatter, accountId);
-      update.addOp(cd.getId(), op);
-      ops.add(op);
-    }
-
-    // Currently there's no way to let some updates succeed even if others fail. Even if there were,
-    // all updates from this operation only happen in All-Users and thus are fully atomic, so
-    // allowing partial failure would have little value.
-    BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
-
-    return Response.ok(
-        ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList()));
-  }
-
-  private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
-      throws BadRequestException {
-    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
-    if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
-      return hasDraft;
-    }
-    try {
-      return Predicate.and(hasDraft, queryBuilder.parse(input.query));
-    } catch (QueryParseException e) {
-      throw new BadRequestException("Invalid query: " + e.getMessage(), e);
-    }
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final HumanCommentFormatter humanCommentFormatter;
-    private final Account.Id accountId;
-    private DeletedDraftCommentInfo result;
-
-    Op(HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
-      this.humanCommentFormatter = humanCommentFormatter;
-      this.accountId = accountId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
-      ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
-      boolean dirty = false;
-      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
-        dirty = true;
-        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
-        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
-        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
-        comments.add(humanCommentFormatter.format(c));
-      }
-      if (dirty) {
-        result = new DeletedDraftCommentInfo();
-        result.change =
-            changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
-        result.deleted = comments.build();
-      }
-      return dirty;
-    }
-
-    @Nullable
-    DeletedDraftCommentInfo getResult() {
-      return result;
-    }
+    return Response.ok(deleteDraftCommentsUtil.deleteDraftComments(rsrc.getUser(), input.query));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
new file mode 100644
index 0000000..2ae3166
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangePredicates;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.CommentJson;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Singleton
+public class DeleteDraftCommentsUtil {
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeQueryBuilder queryBuilder;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeJson.Factory changeJsonFactory;
+  private final Provider<CommentJson> commentJsonProvider;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  public DeleteDraftCommentsUtil(
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeQueryBuilder queryBuilder,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeData.Factory changeDataFactory,
+      ChangeJson.Factory changeJsonFactory,
+      Provider<CommentJson> commentJsonProvider,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil) {
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.queryBuilder = queryBuilder;
+    this.queryProvider = queryProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.changeJsonFactory = changeJsonFactory;
+    this.commentJsonProvider = commentJsonProvider;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+  }
+
+  public ImmutableList<DeletedDraftCommentInfo> deleteDraftComments(
+      IdentifiedUser user, String query) throws RestApiException, UpdateException {
+    CommentJson.HumanCommentFormatter humanCommentFormatter =
+        commentJsonProvider.get().newHumanCommentFormatter();
+    Account.Id accountId = user.getAccountId();
+    Instant now = TimeUtil.now();
+    Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
+    List<Op> ops = new ArrayList<>();
+    for (ChangeData cd :
+        queryProvider
+            .get()
+            // Don't attempt to mutate any changes the user can't currently see.
+            .enforceVisibility(true)
+            .query(predicate(accountId, query))) {
+      BatchUpdate update =
+          updates.computeIfAbsent(cd.project(), p -> batchUpdateFactory.create(p, user, now));
+      Op op = new Op(humanCommentFormatter, accountId);
+      update.addOp(cd.getId(), op);
+      ops.add(op);
+    }
+    // Currently there's no way to let some updates succeed even if others fail. Even if there were,
+    // all updates from this operation only happen in All-Users and thus are fully atomic, so
+    // allowing partial failure would have little value.
+    BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
+    return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
+  }
+
+  private Predicate<ChangeData> predicate(Account.Id accountId, String query)
+      throws BadRequestException {
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
+    if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(query)).isEmpty()) {
+      return hasDraft;
+    }
+    try {
+      return Predicate.and(hasDraft, queryBuilder.parse(query));
+    } catch (QueryParseException e) {
+      throw new BadRequestException("Invalid query: " + e.getMessage(), e);
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final CommentJson.HumanCommentFormatter humanCommentFormatter;
+    private final Account.Id accountId;
+    private DeletedDraftCommentInfo result;
+
+    Op(CommentJson.HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
+      this.humanCommentFormatter = humanCommentFormatter;
+      this.accountId = accountId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
+      ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
+      boolean dirty = false;
+      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+        dirty = true;
+        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
+        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
+        comments.add(humanCommentFormatter.format(c));
+      }
+      if (dirty) {
+        result = new DeletedDraftCommentInfo();
+        result.change =
+            changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
+        result.deleted = comments.build();
+      }
+      return dirty;
+    }
+
+    @Nullable
+    DeletedDraftCommentInfo getResult() {
+      return result;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 1a8f07a..8a8d2ca 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -87,6 +87,13 @@
   @Override
   public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.REBASE);
+
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .checkStatePermitsWrite();
+
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
         ObjectInserter oi = repo.newObjectInserter();
@@ -94,7 +101,7 @@
         RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      RebaseUtil.verifyRebasePreconditions(projectCache, patchSetUtil, rw, rsrc);
+      rebaseUtil.verifyRebasePreconditions(rw, rsrc.getNotes(), rsrc.getPatchSet());
 
       RebaseChangeOp rebaseOp =
           rebaseUtil.getRebaseOp(
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
index 4754c69..786bba7 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChain.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -113,7 +113,11 @@
   @Override
   public Response<RebaseChainInfo> apply(ChangeResource tipRsrc, RebaseInput input)
       throws IOException, PermissionBackendException, RestApiException, UpdateException {
+    tipRsrc.permissions().check(ChangePermission.REBASE);
+
     Project.NameKey project = tipRsrc.getProject();
+    projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
+
     CurrentUser user = tipRsrc.getUser();
 
     List<Change.Id> upToDateAncestors = new ArrayList<>();
@@ -136,7 +140,8 @@
 
         RevisionResource revRsrc =
             new RevisionResource(changeResourceFactory.create(changeData, user), ps);
-        RebaseUtil.verifyRebasePreconditions(projectCache, patchSetUtil, rw, revRsrc);
+        revRsrc.permissions().check(ChangePermission.REBASE);
+        rebaseUtil.verifyRebasePreconditions(rw, changeData.notes(), ps);
 
         boolean isUpToDate = false;
         RebaseChangeOp rebaseOp = null;
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
index 491b5cd..f3c741f 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -37,6 +37,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.stream.Collectors;
 import javax.inject.Inject;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -216,12 +217,19 @@
                   Arrays.asList(
                       cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE)))
               .build();
+      ImmutableList<String> refPatterns =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH)))
+              .build();
       LabelAttributes attributes =
           LabelAttributes.create(
               function == null ? "MaxWithBlock" : function,
               canOverride,
               ignoreSelfApproval,
-              values);
+              values,
+              refPatterns);
       labelTypes.put(labelName, attributes);
     }
     return labelTypes;
@@ -320,6 +328,15 @@
       default:
         break;
     }
+    if (!attributes.refPatterns().isEmpty()) {
+      builder.setApplicabilityExpression(
+          SubmitRequirementExpression.of(
+              String.join(
+                  " OR ",
+                  attributes.refPatterns().stream()
+                      .map(b -> "branch:\\\"" + b + "\\\"")
+                      .collect(Collectors.toList()))));
+    }
     return builder.build();
   }
 
@@ -435,13 +452,16 @@
 
     abstract ImmutableList<String> values();
 
+    abstract ImmutableList<String> refPatterns();
+
     static LabelAttributes create(
         String function,
         boolean canOverride,
         boolean ignoreSelfApproval,
-        ImmutableList<String> values) {
+        ImmutableList<String> values,
+        ImmutableList<String> refPatterns) {
       return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes(
-          function, canOverride, ignoreSelfApproval, values);
+          function, canOverride, ignoreSelfApproval, values, refPatterns);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ConstantPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
similarity index 85%
rename from java/com/google/gerrit/server/query/change/ConstantPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
index f0a85fe..c493fa4 100644
--- a/java/com/google/gerrit/server/query/change/ConstantPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
@@ -12,8 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Singleton;
 
 /**
diff --git a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
similarity index 95%
rename from java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
index 5a51f5d..e392989 100644
--- a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -22,6 +22,8 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/query/FileEditsPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
similarity index 98%
rename from java/com/google/gerrit/server/query/FileEditsPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
index 7058765..515dc4a 100644
--- a/java/com/google/gerrit/server/query/FileEditsPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Iterables;
diff --git a/java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
similarity index 95%
rename from java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
index 4ff40a4..1774628 100644
--- a/java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import static com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder.SUBMODULE_UPDATE_HAS_ARG;
 
@@ -23,6 +23,8 @@
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
similarity index 89%
rename from java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
index 22891bc..eb7f666 100644
--- a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
@@ -12,9 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b828037..936b448 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -193,6 +194,7 @@
     install(new AuthModule(authConfig));
     install(new GerritApiModule());
     install(new ProjectQueryBuilderModule());
+    install(new DefaultRefLogIdentityProvider.Module());
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
     install(new DefaultPermissionBackendModule());
diff --git a/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
new file mode 100644
index 0000000..27e4b17
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2023 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.util.cli;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.reflect.ClassPath;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Utility to generate Protocol Buffers (*.proto) files from existing POJO API types.
+ *
+ * <p>Usage:
+ *
+ * <ul>
+ *   <li>Print proto representation of all API objects: {@code bazelisk run
+ *       java/com/google/gerrit/util/cli:protogen}
+ * </ul>
+ */
+public class ApiProtocolBufferGenerator {
+  private static String NOTICE =
+      "// Copyright (C) 2023 The Android Open Source Project\n"
+          + "//\n"
+          + "// Licensed under the Apache License, Version 2.0 (the \"License\");\n"
+          + "// you may not use this file except in compliance with the License.\n"
+          + "// You may obtain a copy of the License at\n"
+          + "//\n"
+          + "// http://www.apache.org/licenses/LICENSE-2.0\n"
+          + "//\n"
+          + "// Unless required by applicable law or agreed to in writing, software\n"
+          + "// distributed under the License is distributed on an \"AS IS\" BASIS,\n"
+          + "// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
+          + "// See the License for the specific language governing permissions and\n"
+          + "// limitations under the License.";
+
+  private static String PACKAGE = "com.google.gerrit.extensions.common";
+
+  public static void main(String[] args) {
+    try {
+      ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+          .filter(c -> c.getPackageName().equalsIgnoreCase(PACKAGE))
+          .filter(c -> c.getName().endsWith("Input") || c.getName().endsWith("Info"))
+          .map(clazz -> clazz.load())
+          .forEach(ApiProtocolBufferGenerator::exportSingleClass);
+    } catch (Exception e) {
+      System.err.println(e);
+    }
+  }
+
+  private static void exportSingleClass(Class<?> clazz) {
+    StringBuilder proto = new StringBuilder(NOTICE);
+    proto.append("\n\nsyntax = \"proto3\";");
+    proto.append("\n\npackage gerrit.api;");
+    proto.append("\n\noption java_package = \"" + PACKAGE + "\";");
+
+    int fieldNumber = 1;
+
+    proto.append("\n\n\nmessage " + clazz.getSimpleName() + " {\n");
+
+    for (Field f : clazz.getFields()) {
+      Class<?> type = f.getType();
+
+      if (type.isAssignableFrom(List.class)) {
+        ParameterizedType list = (ParameterizedType) f.getGenericType();
+        Class<?> genericType = (Class<?>) list.getActualTypeArguments()[0];
+        String protoType =
+            protoType(genericType)
+                .orElseThrow(() -> new IllegalStateException("unknown type: " + genericType));
+        proto.append(
+            String.format(
+                "repeated %s %s = %d;\n", protoType, protoName(f.getName()), fieldNumber));
+      } else if (type.isAssignableFrom(Map.class)) {
+        ParameterizedType map = (ParameterizedType) f.getGenericType();
+        Class<?> key = (Class<?>) map.getActualTypeArguments()[0];
+        if (map.getActualTypeArguments()[1] instanceof ParameterizedType) {
+          // TODO: This is list multimap which proto doesn't support. Move to
+          // it's own types.
+          proto.append(
+              "reserved "
+                  + fieldNumber
+                  + "; // TODO(hiesel): Add support for map<?,repeated <?>>\n");
+        } else {
+          Class<?> value = (Class<?>) map.getActualTypeArguments()[1];
+          String keyProtoType =
+              protoType(key).orElseThrow(() -> new IllegalStateException("unknown type: " + key));
+          String valueProtoType =
+              protoType(value)
+                  .orElseThrow(() -> new IllegalStateException("unknown type: " + value));
+          proto.append(
+              String.format(
+                  "map<%s,%s> %s = %d;\n",
+                  keyProtoType, valueProtoType, protoName(f.getName()), fieldNumber));
+        }
+      } else if (protoType(type).isPresent()) {
+        proto.append(
+            String.format(
+                "%s %s = %d;\n", protoType(type).get(), protoName(f.getName()), fieldNumber));
+      } else {
+        proto.append(
+            "reserved "
+                + fieldNumber
+                + "; // TODO(hiesel): Add support for "
+                + type.getName()
+                + "\n");
+      }
+      fieldNumber++;
+    }
+    proto.append("}");
+
+    System.out.println(proto);
+  }
+
+  private static Optional<String> protoType(Class<?> type) {
+    if (isInt(type)) {
+      return Optional.of("int32");
+    } else if (isLong(type)) {
+      return Optional.of("int64");
+    } else if (isChar(type)) {
+      return Optional.of("string");
+    } else if (isShort(type)) {
+      return Optional.of("int32");
+    } else if (isShort(type)) {
+      return Optional.of("int32");
+    } else if (isBoolean(type)) {
+      return Optional.of("bool");
+    } else if (type.isAssignableFrom(String.class)) {
+      return Optional.of("string");
+    } else if (type.isAssignableFrom(Timestamp.class)) {
+      // See https://gerrit-review.googlesource.com/Documentation/rest-api.html#timestamp
+      return Optional.of("string");
+    } else if (type.getPackageName().startsWith("com.google.gerrit.extensions")) {
+      return Optional.of("gerrit.api." + type.getSimpleName());
+    }
+    return Optional.empty();
+  }
+
+  private static boolean isInt(Class<?> type) {
+    return type.isAssignableFrom(Integer.class) || type.isAssignableFrom(int.class);
+  }
+
+  private static boolean isLong(Class<?> type) {
+    return type.isAssignableFrom(Long.class) || type.isAssignableFrom(long.class);
+  }
+
+  private static boolean isChar(Class<?> type) {
+    return type.isAssignableFrom(Character.class) || type.isAssignableFrom(char.class);
+  }
+
+  private static boolean isShort(Class<?> type) {
+    return type.isAssignableFrom(Short.class) || type.isAssignableFrom(short.class);
+  }
+
+  private static boolean isBoolean(Class<?> type) {
+    return type.isAssignableFrom(Boolean.class) || type.isAssignableFrom(boolean.class);
+  }
+
+  private static String protoName(String name) {
+    return CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, name);
+  }
+}
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index ebcc67e..b464f32 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -2,7 +2,10 @@
 
 java_library(
     name = "cli",
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["ApiProtocolBufferGenerator.java"],
+    ),
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
@@ -14,3 +17,15 @@
         "//lib/guice:guice-assistedinject",
     ],
 )
+
+# Util to generate *.proto files from *Info and *Input objects
+java_binary(
+    name = "protogen",
+    srcs = ["ApiProtocolBufferGenerator.java"],
+    main_class = "com.google.gerrit.util.cli.ApiProtocolBufferGenerator",
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//lib:guava",
+        "//lib:protobuf",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 74bd94e..522013e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -399,7 +399,11 @@
       String changeId = r2.getChangeId();
       requestScopeOperations.setApiUser(user.id());
       AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
-      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
     }
 
     @Test
@@ -449,7 +453,11 @@
       String changeId = r2.getChangeId();
       requestScopeOperations.setApiUser(user.id());
       AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
-      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
     }
 
     @Test
@@ -473,7 +481,11 @@
       // Rebase the second
       String changeId = r2.getChangeId();
       AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
-      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
     }
 
     @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 242c278..ab2f358 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
@@ -424,6 +425,26 @@
   }
 
   @Test
+  public void checkSubmitRequirement_verifiesUploader() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "Code-Review", 2);
+    TestAccount anotherUser = accountCreator.createValid("anotherUser");
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Foo", /* submittabilityExpression= */ "uploader:" + anotherUser.id());
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    in =
+        createSubmitRequirementInput(
+            "Foo", /* submittabilityExpression= */ "uploader:" + r.getChange().change().getOwner());
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
   public void submitRequirement_withLabelEqualsMax() throws Exception {
     configSubmitRequirement(
         project,
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
index 74bfe0f..9d37497 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -260,6 +261,96 @@
   }
 
   @Test
+  public void migrateBlockingLabel_withBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withMultipleBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "refs/heads/develop"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"refs/heads/develop\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexAndNonRegexBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
   public void migrationIsIdempotent() throws Exception {
     String oldRefsConfigId;
     try (Repository repo = repoManager.openRepository(project)) {
@@ -381,6 +472,21 @@
     gApi.projects().name(project.get()).label(labelName).create(input);
   }
 
+  private void createLabelWithBranch(
+      String labelName,
+      String function,
+      boolean ignoreSelfApproval,
+      ImmutableList<String> refPatterns)
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = labelName;
+    input.function = function;
+    input.ignoreSelfApproval = ignoreSelfApproval;
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = refPatterns;
+    gApi.projects().name(project.get()).label(labelName).create(input);
+  }
+
   @CanIgnoreReturnValue
   private SubmitRequirementApi createSubmitRequirement(
       String name, String submitExpression, boolean canOverride) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index d055875..62ef118 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.RefLogIdentityProvider;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.DefaultRealm;
@@ -57,6 +58,7 @@
 
 public class EmailIT extends AbstractDaemonTest {
   @Inject private @AnonymousCowardName String anonymousCowardName;
+  @Inject private RefLogIdentityProvider refLogIdentityProvider;
   @Inject private @CanonicalWebUrl Provider<String> canonicalUrl;
   @Inject private @EnablePeerIPInReflogRecord boolean enablePeerIPInReflogRecord;
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
@@ -283,6 +285,7 @@
             authConfig,
             realm,
             anonymousCowardName,
+            refLogIdentityProvider,
             canonicalUrl,
             enablePeerIPInReflogRecord,
             accountCache,
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 2123ac2..15baa78 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1262,7 +1262,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
                 + "\n"
@@ -1274,7 +1274,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
                 + "\n"
@@ -1288,7 +1288,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
                 + "\n"
@@ -1300,7 +1300,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
                 + "\n"
@@ -1312,7 +1312,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
                 + "\n"
@@ -1324,7 +1324,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index b2a0ded..e011ffc 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -68,10 +68,7 @@
       values = {"UiFeature__patchset_comments", "UiFeature__submit_requirements_ui"})
   public void configOverride_defaultFeatureDisabled() {
     assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
-    assertThat(
-            experimentFeatures.isFeatureEnabled(
-                ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
-        .isFalse();
+    assertThat(experimentFeatures.isFeatureEnabled("UiFeature__patchset_comments")).isFalse();
     assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 6c629c9..3b97372 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -29,6 +29,7 @@
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
@@ -49,6 +50,7 @@
 import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
 public class ChangeOperationsImplTest extends AbstractDaemonTest {
@@ -611,6 +613,155 @@
   }
 
   @Test
+  public void createdChangeHasOwnerAsAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    TestAccount changeOwner = accountOperations.account(Account.id(change.owner._accountId)).get();
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(changeOwner.fullname().get());
+    assertThat(revision.commit.author.email).isEqualTo(changeOwner.preferredEmail().get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwnerAsAuthor() throws Exception {
+    String changeOwnerName = "Change Owner";
+    String changeOwnerEmail = "change-owner@example.com";
+    Account.Id changeOwner =
+        accountOperations
+            .newAccount()
+            .fullname(changeOwnerName)
+            .preferredEmail(changeOwnerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(changeOwnerName);
+    assertThat(revision.commit.author.email).isEqualTo(changeOwnerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedAuthor() throws Exception {
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    Change.Id changeId = changeOperations.newChange().author(author).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(author.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedAuthorIdent() throws Exception {
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+    Change.Id changeId = changeOperations.newChange().authorIdent(authorIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorIdent.getName());
+    assertThat(revision.commit.author.email).isEqualTo(authorIdent.getEmailAddress());
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithAuthorAndAuthorIdent() throws Exception {
+    Account.Id author = accountOperations.newAccount().create();
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () -> changeOperations.newChange().author(author).authorIdent(authorIdent).create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("author and authorIdent cannot be set together");
+  }
+
+  @Test
+  public void createdChangeHasOwnerAsCommitter() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    TestAccount changeOwner = accountOperations.account(Account.id(change.owner._accountId)).get();
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(changeOwner.fullname().get());
+    assertThat(revision.commit.committer.email).isEqualTo(changeOwner.preferredEmail().get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwnerAsCommitter() throws Exception {
+    String changeOwnerName = "Change Owner";
+    String changeOwnerEmail = "change-owner@example.com";
+    Account.Id changeOwner =
+        accountOperations
+            .newAccount()
+            .fullname(changeOwnerName)
+            .preferredEmail(changeOwnerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(changeOwnerName);
+    assertThat(revision.commit.committer.email).isEqualTo(changeOwnerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitter() throws Exception {
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().committer(committer).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(committer.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitterIdent() throws Exception {
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+    Change.Id changeId = changeOperations.newChange().committerIdent(committerIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerIdent.getName());
+    assertThat(revision.commit.committer.email).isEqualTo(committerIdent.getEmailAddress());
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithCommitterAndCommitterIdent() throws Exception {
+    Account.Id committer = accountOperations.newAccount().create();
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .committer(committer)
+                    .committerIdent(committerIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("committer and committerIdent cannot be set together");
+  }
+
+  @Test
   public void createdChangeHasSpecifiedTopic() throws Exception {
     Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
 
@@ -795,6 +946,173 @@
   }
 
   @Test
+  public void newPatchsetCanHaveDifferentUploader() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo currentPatchsetRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentPatchsetRevision.uploader._accountId).isEqualTo(changeOwner.get());
+
+    Account.Id newUploader = accountOperations.newAccount().create();
+    changeOperations.change(changeId).newPatchset().uploader(newUploader).create();
+
+    change = getChangeFromServer(changeId);
+    currentPatchsetRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentPatchsetRevision.uploader._accountId).isEqualTo(newUploader.get());
+  }
+
+  @Test
+  public void createdPatchsetPreviousAuthorAsAuthor() throws Exception {
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    Change.Id changeId = changeOperations.newChange().author(author).create();
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+
+    changeOperations.change(changeId).newPatchset().create();
+    change = getChangeFromServer(changeId);
+    revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    changeOperations.change(changeId).newPatchset().author(author).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(author.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedAuthorIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+    changeOperations.change(changeId).newPatchset().authorIdent(authorIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorIdent.getName());
+    assertThat(revision.commit.author.email).isEqualTo(authorIdent.getEmailAddress());
+  }
+
+  @Test
+  public void patchsetCannotBeCreatedWithAuthorAndAuthorIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    Account.Id author = accountOperations.newAccount().create();
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .change(changeId)
+                    .newPatchset()
+                    .author(author)
+                    .authorIdent(authorIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("author and authorIdent cannot be set together");
+  }
+
+  @Test
+  public void createdPatchsetPreviousCommitterAsCommitter() throws Exception {
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().committer(committer).create();
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+
+    changeOperations.change(changeId).newPatchset().create();
+    change = getChangeFromServer(changeId);
+    revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedCommitter() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    changeOperations.change(changeId).newPatchset().committer(committer).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(committer.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedCommitterIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+    changeOperations.change(changeId).newPatchset().committerIdent(committerIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerIdent.getName());
+    assertThat(revision.commit.committer.email).isEqualTo(committerIdent.getEmailAddress());
+  }
+
+  @Test
+  public void patchsetCannotBeCreatedWithCommitterAndCommitterIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    Account.Id committer = accountOperations.newAccount().create();
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .change(changeId)
+                    .newPatchset()
+                    .committer(committer)
+                    .committerIdent(committerIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("committer and committerIdent cannot be set together");
+  }
+
+  @Test
   public void newPatchsetCanHaveUpdatedCommitMessage() throws Exception {
     Change.Id changeId = changeOperations.newChange().commitMessage("Old message").create();
 
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index f65e823..06ea8b6 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -60,15 +60,13 @@
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
 
-    // Pick any known experiment enabled by default;
-    String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
-    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).isEmpty();
 
     org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
     serverConfig.setStringList(
         "experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
     serverConfig.setStringList(
-        "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
+        "experiments", null, "disabled", ImmutableList.of("DisabledFeature"));
     ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
     IndexServlet servlet =
         new IndexServlet(
@@ -97,7 +95,6 @@
                 + "\\x5b\\x5d\\x7d');");
     ImmutableSet<String> enabledDefaults =
         ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
-            .filter(e -> !e.equals(disabledDefault))
             .collect(ImmutableSet.toImmutableSet());
     List<String> expectedEnabled = new ArrayList<>();
     expectedEnabled.add("NewFeature");
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 855a0bc..30ae4aa 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -92,6 +92,7 @@
             bind(AccountCache.class).toInstance(accountCache);
             bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
             bind(Realm.class).toInstance(mockRealm);
+            install(new DefaultRefLogIdentityProvider.Module());
           }
         };
 
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index be8f1f9..1e6ba3a 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -180,6 +181,7 @@
 
             install(new DefaultUrlFormatterModule());
             install(NoteDbModule.forTest());
+            install(new DefaultRefLogIdentityProvider.Module());
             bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
             bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
             bind(new TypeLiteral<ImmutableSet<String>>() {})
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 61b5e55..9cd002e 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -825,7 +825,8 @@
         ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
     for (String strangeTag : strangeTags) {
       Change c = newChange();
-      CurrentUser otherUserAsOwner = userFactory.runAs(null, changeOwner.getAccountId(), otherUser);
+      CurrentUser otherUserAsOwner =
+          userFactory.runAs(/* remotePeer= */ null, changeOwner.getAccountId(), otherUser);
       ChangeUpdate update = newUpdate(c, otherUserAsOwner);
       update.putApproval(LabelId.CODE_REVIEW, (short) 2);
       update.setTag(strangeTag);
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index b53de89..25f2f98 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -354,7 +354,8 @@
   @Test
   public void realUser() throws Exception {
     Change c = newChange();
-    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(/* remotePeer= */ null, otherUserId, changeOwner);
     ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
     update.setChangeMessage("Message on behalf of other user");
     update.commit();
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 5e6803e..527e78e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -399,7 +399,9 @@
 
     IdentifiedUser impersonatedChangeOwner =
         this.userFactory.runAs(
-            null, changeOwner.getAccountId(), requireNonNull(otherUser).getRealUser());
+            /* remotePeer= */ null,
+            changeOwner.getAccountId(),
+            requireNonNull(otherUser).getRealUser());
     ChangeUpdate impersonatedChangeMessageUpdate = newUpdate(c, impersonatedChangeOwner);
     impersonatedChangeMessageUpdate.setChangeMessage("Other comment on behalf of");
     impersonatedChangeMessageUpdate.commit();
diff --git a/modules/jgit b/modules/jgit
index 801a56b..a190130 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 801a56b48a7fe3c6e171073211cc62194184fe79
+Subproject commit a1901305b26ed5e0116f138bc02837713d2cf5c3
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index ce9ce2d..e3b3ad4 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -201,7 +201,7 @@
   syntax_highlighting?: boolean;
   tab_size: number;
   font_size: number;
-  // TODO: Missing documentation
+  // Hides the FILE and LOST diff rows. Default is TRUE.
   show_file_comment_button?: boolean;
   line_wrapping?: boolean;
 }
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 89c7622..f915432 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -319,6 +319,4 @@
 
 export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
-export const SHOWN_ITEMS_COUNT = 25;
-
 export const WAITING = 'Waiting';
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index d3562f2..893a997 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -10,7 +10,6 @@
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
 import {fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
@@ -43,21 +42,19 @@
   /**
    * Offset of currently visible query results.
    */
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  @state() private hasNewGroupName = false;
+  @state() hasNewGroupName = false;
 
-  @state() private createNewCapability = false;
+  @state() createNewCapability = false;
 
-  // private but used in test
   @state() groups: GroupInfo[] = [];
 
-  @state() private groupsPerPage = 25;
+  @state() groupsPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -108,7 +105,7 @@
           </tbody>
           <tbody class=${this.loading ? 'loading' : ''}>
             ${this.groups
-              .slice(0, SHOWN_ITEMS_COUNT)
+              .slice(0, this.groupsPerPage)
               .map(group => this.renderGroupList(group))}
           </tbody>
         </table>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
index e9b7ea0..fe5aa22 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -15,7 +15,6 @@
 import {GerritView} from '../../../services/router/router-model';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
@@ -117,7 +116,9 @@
     });
 
     test('groups', () => {
-      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.groupsPerPage);
     });
 
     test('maybeOpenCreateModal', async () => {
@@ -145,7 +146,9 @@
     });
 
     test('groups', () => {
-      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.groupsPerPage);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 8d5689c..96688e9 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -6,7 +6,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {page} from '../../../utils/page-wrapper-utils';
 import {GroupId, GroupName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -16,6 +15,8 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
 import {createGroupUrl} from '../../../models/views/group';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -32,6 +33,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   static override get styles() {
     return [
       formStyles,
@@ -86,8 +89,7 @@
       return this.restApiService.getGroupConfig(name).then(group => {
         if (!group) return;
         const groupId = String(group.group_id!) as GroupId;
-        // TODO: Use navigation service instead of `page.show()` directly.
-        page.show(createGroupUrl({groupId}));
+        this.getNavigation().setUrl(createGroupUrl({groupId}));
       });
     });
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
index 2a0b539..6e36d8c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-create-group-dialog';
 import {GrCreateGroupDialog} from './gr-create-group-dialog';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   mockPromise,
   queryAndAssert,
@@ -15,6 +14,8 @@
 import {IronInputElement} from '@polymer/iron-input';
 import {GroupId} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-create-group-dialog tests', () => {
   let element: GrCreateGroupDialog;
@@ -68,9 +69,9 @@
       Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
     );
 
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     await element.handleCreateGroup();
-    assert.isTrue(showStub.calledWith('/admin/groups/551'));
+    assert.isTrue(setUrlStub.calledWith('/admin/groups/551'));
   });
 
   test('test for unsuccessful group creation', async () => {
@@ -81,8 +82,8 @@
       Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
     );
 
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     await element.handleCreateGroup();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index bb59ccc..ed57830 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -7,7 +7,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   BranchName,
   GroupId,
@@ -24,6 +23,8 @@
 import {fireEvent} from '../../../utils/event-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {createRepoUrl} from '../../../models/views/repo';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -71,6 +72,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.query = (input: string) => this.getRepoSuggestions(input);
@@ -195,8 +198,7 @@
     );
     if (repoRegistered.status === 201) {
       this.repoCreated = true;
-      // TODO: Use navigation service instead of `page.show()` directly.
-      page.show(createRepoUrl({repo: this.repoConfig.name}));
+      this.getNavigation().setUrl(createRepoUrl({repo: this.repoConfig.name}));
     }
     return repoRegistered;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 383b4a7..a2de840 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -9,7 +9,6 @@
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
@@ -34,17 +33,15 @@
   /**
    * Offset of currently visible query results.
    */
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  // private but used in test
   @state() plugins?: PluginInfoWithName[];
 
-  @state() private pluginsPerPage = 25;
+  @state() pluginsPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -107,7 +104,7 @@
     return html`
       <tbody>
         ${this.plugins
-          ?.slice(0, SHOWN_ITEMS_COUNT)
+          ?.slice(0, this.pluginsPerPage)
           .map(plugin => this.renderPluginList(plugin))}
       </tbody>
     `;
@@ -176,7 +173,7 @@
   }
 
   private computePluginUrl(id: string) {
-    return getBaseUrl() + '/' + encodeURL(id, true);
+    return getBaseUrl() + '/' + encodeURL(id);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
index 4057e52..34c88be 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
@@ -17,7 +17,6 @@
 import {PluginInfo} from '../../../types/common';
 import {GerritView} from '../../../services/router/router-model';
 import {PageErrorEvent} from '../../../types/events';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
@@ -334,7 +333,9 @@
     });
 
     test('plugins', () => {
-      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.pluginsPerPage);
     });
   });
 
@@ -348,7 +349,9 @@
     });
 
     test('plugins', () => {
-      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.pluginsPerPage);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 7eef7a4..41ad3f8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -25,7 +25,6 @@
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -51,36 +50,30 @@
   @property({type: Object})
   params?: RepoViewState;
 
-  // private but used in test
   @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
-  // private but used in test
   @state() isOwner = false;
 
-  @state() private loggedIn = false;
+  @state() loggedIn = false;
 
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  // private but used in test
   @state() repo?: RepoName;
 
-  // private but used in test
   @state() items?: BranchInfo[] | TagInfo[];
 
-  @state() private readonly itemsPerPage = 25;
+  @state() readonly itemsPerPage = 25;
 
-  @state() private loading = true;
+  @state() loading = true;
 
-  @state() private filter?: string;
+  @state() filter?: string;
 
-  @state() private refName?: GitRef;
+  @state() refName?: GitRef;
 
-  @state() private newItemName = false;
+  @state() newItemName = false;
 
-  // private but used in test
   @state() isEditing = false;
 
-  // private but used in test
   @state() revisedRef?: GitRef;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -185,7 +178,7 @@
           </tbody>
           <tbody class=${this.loading ? 'loading' : ''}>
             ${this.items
-              ?.slice(0, SHOWN_ITEMS_COUNT)
+              ?.slice(0, this.itemsPerPage)
               .map((item, index) => this.renderItemList(item, index))}
           </tbody>
         </table>
@@ -445,7 +438,7 @@
     // TODO: Replace with `createRepoUrl()`, but be aware that `encodeURL()`
     // gets `false` as a second parameter here. The router pattern in gr-router
     // does not handle the filter URLs, if the repo is not encoded!
-    return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
+    return `/admin/repos/${encodeURL(repo ?? '')},${detailType}`;
   }
 
   private computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
index ec1bb82..9a67fda 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-repo-detail-list';
 import {GrRepoDetailList} from './gr-repo-detail-list';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   addListenerForTest,
   mockPromise,
@@ -32,9 +31,10 @@
 import {PageErrorEvent} from '../../../types/events';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {RepoDetailView} from '../../../models/views/repo';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function branchGenerator(counter: number) {
   return {
@@ -96,7 +96,7 @@
         html`<gr-repo-detail-list></gr-repo-detail-list>`
       );
       element.detailType = RepoDetailView.BRANCHES;
-      sinon.stub(page, 'show');
+      sinon.stub(testResolver(navigationToken), 'setUrl');
     });
 
     suite('list of repo branches', () => {
@@ -2339,7 +2339,7 @@
         html`<gr-repo-detail-list></gr-repo-detail-list>`
       );
       element.detailType = RepoDetailView.TAGS;
-      sinon.stub(page, 'show');
+      sinon.stub(testResolver(navigationToken), 'setUrl');
     });
 
     suite('list of repo tags', () => {
@@ -2391,7 +2391,9 @@
       });
 
       test('items', () => {
-        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+        const table = queryAndAssert(element, 'table');
+        const rows = table.querySelectorAll('tr.table');
+        assert.equal(rows.length, element.itemsPerPage);
       });
     });
 
@@ -2411,7 +2413,9 @@
       });
 
       test('items', () => {
-        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+        const table = queryAndAssert(element, 'table');
+        const rows = table.querySelectorAll('tr.table');
+        assert.equal(rows.length, element.itemsPerPage);
       });
     });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 2fd7e79..055cb30 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -8,7 +8,7 @@
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectInfoWithName, WebLinkInfo} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {RepoState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {tableStyles} from '../../../styles/gr-table-styles';
@@ -39,23 +39,18 @@
   @property({type: Object})
   params?: AdminViewState;
 
-  // private but used in test
   @state() offset = 0;
 
-  @state() private newRepoName = false;
+  @state() newRepoName = false;
 
-  @state() private createNewCapability = false;
+  @state() createNewCapability = false;
 
-  // private but used in test
   @state() repos: ProjectInfoWithName[] = [];
 
-  // private but used in test
   @state() reposPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  // private but used in test
   @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
@@ -147,7 +142,7 @@
   }
 
   private renderRepoList() {
-    const shownRepos = this.repos.slice(0, SHOWN_ITEMS_COUNT);
+    const shownRepos = this.repos.slice(0, this.reposPerPage);
     return shownRepos.map(item => this.renderRepo(item));
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
index 5b65942..906b733 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-repo-list';
 import {GrRepoList} from './gr-repo-list';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   mockPromise,
   queryAndAssert,
@@ -17,12 +16,14 @@
   ProjectInfoWithName,
   RepoName,
 } from '../../../types/common';
-import {RepoState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../api/rest-api';
 import {GerritView} from '../../../services/router/router-model';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function createRepo(name: string, counter: number) {
   return {
@@ -51,7 +52,7 @@
   let repos: ProjectInfoWithName[];
 
   setup(async () => {
-    sinon.stub(page, 'show');
+    sinon.stub(testResolver(navigationToken), 'setUrl');
     element = await fixture(html`<gr-repo-list></gr-repo-list>`);
   });
 
@@ -614,7 +615,9 @@
     });
 
     test('shownRepos', () => {
-      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.reposPerPage);
     });
 
     test('maybeOpenCreateModal', () => {
@@ -645,7 +648,9 @@
     });
 
     test('shownRepos', () => {
-      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.reposPerPage);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 8d6d89a..3599224 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -25,7 +25,7 @@
   RepoState,
   SubmitType,
 } from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
@@ -36,8 +36,9 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {BindValueChangeEvent} from '../../../types/events';
 import {deepClone} from '../../../utils/deep-util';
-import {LitElement, PropertyValues, css, html} from 'lit';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {createSearchUrl} from '../../../models/views/search';
 import {userModelToken} from '../../../models/user/user-model';
@@ -150,16 +151,6 @@
           color: var(--deemphasized-text-color);
           content: ' *';
         }
-        .loading,
-        .hide {
-          display: none;
-        }
-        #loading.loading {
-          display: block;
-        }
-        #loading:not(.loading) {
-          display: none;
-        }
         #options .repositorySettings {
           display: none;
         }
@@ -187,49 +178,48 @@
             >
           </div>
         </div>
-        <div id="loading" class=${this.loading ? 'loading' : ''}>
-          Loading...
-        </div>
-        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
-          ${this.renderDownloadCommands()}
-          <h2
-            id="configurations"
-            class="heading-2 ${configChanged ? 'edited' : ''}"
-          >
-            Configurations
-          </h2>
-          <div id="form">
-            <fieldset>
-              ${this.renderDescription()} ${this.renderRepoOptions()}
-              ${this.renderPluginConfig()}
-              <gr-button
-                ?disabled=${this.readOnly || !configChanged}
-                @click=${this.handleSaveRepoConfig}
-                >Save changes</gr-button
-              >
-            </fieldset>
-            <gr-endpoint-decorator name="repo-config">
-              <gr-endpoint-param
-                name="repoName"
-                .value=${this.repo}
-              ></gr-endpoint-param>
-              <gr-endpoint-param
-                name="readOnly"
-                .value=${this.readOnly}
-              ></gr-endpoint-param>
-            </gr-endpoint-decorator>
-          </div>
-        </div>
+        ${when(
+          this.loading || !this.repoConfig,
+          () => html`<div id="loading">Loading...</div>`,
+          () => html`<div id="loadedContent">
+            ${this.renderDownloadCommands()}
+            <h2
+              id="configurations"
+              class="heading-2 ${configChanged ? 'edited' : ''}"
+            >
+              Configurations
+            </h2>
+            <div id="form">
+              <fieldset>
+                ${this.renderDescription()} ${this.renderRepoOptions()}
+                ${this.renderPluginConfig()}
+                <gr-button
+                  ?disabled=${this.readOnly || !configChanged}
+                  @click=${this.handleSaveRepoConfig}
+                  >Save changes</gr-button
+                >
+              </fieldset>
+              <gr-endpoint-decorator name="repo-config">
+                <gr-endpoint-param
+                  name="repoName"
+                  .value=${this.repo}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="readOnly"
+                  .value=${this.readOnly}
+                ></gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </div>`
+        )}
       </div>
     `;
   }
 
   private renderDownloadCommands() {
+    if (!this.schemes.length) return nothing;
     return html`
-      <div
-        id="downloadContent"
-        class=${!this.schemes || !this.schemes.length ? 'hide' : ''}
-      >
+      <div id="downloadContent">
         <h2 id="download" class="heading-2">Download</h2>
         <fieldset>
           <gr-download-commands
@@ -252,6 +242,7 @@
   }
 
   private renderDescription() {
+    assertIsDefined(this.repoConfig, 'repoConfig');
     return html`
       <h3 id="Description" class="heading-3">Description</h3>
       <fieldset>
@@ -263,7 +254,7 @@
           rows="4"
           monospace
           ?disabled=${this.readOnly}
-          .text=${this.repoConfig?.description ?? ''}
+          .text=${this.repoConfig.description ?? ''}
           @text-changed=${this.handleDescriptionTextChanged}
         ></gr-textarea>
       </fieldset>
@@ -725,8 +716,9 @@
 
   private renderPluginConfig() {
     const pluginData = this.computePluginData();
+    if (!pluginData.length) return nothing;
     return html` <div
-      class="pluginConfig ${!pluginData || !pluginData.length ? 'hide' : ''}"
+      class="pluginConfig"
       @plugin-config-changed=${this.handlePluginConfigChanged}
     >
       <h3 class="heading-3">Plugins</h3>
@@ -762,6 +754,12 @@
   // private but used in test
   async loadRepo() {
     if (!this.repo) return Promise.resolve();
+    this.repoConfig = undefined;
+    this.originalConfig = undefined;
+    this.loading = true;
+    this.weblinks = [];
+    this.schemesObj = undefined;
+    this.readOnly = true;
 
     const promises = [];
 
@@ -1121,6 +1119,7 @@
 
   private handleDescriptionTextChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.description === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       description: e.detail.value,
@@ -1130,6 +1129,7 @@
 
   private handleStateSelectBindValueChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.state === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       state: e.detail.value as RepoState,
@@ -1139,6 +1139,7 @@
 
   private handleSubmitTypeSelectBindValueChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.submit_type === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       submit_type: e.detail.value as SubmitType,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index c013c9e..4deb99a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -157,14 +157,17 @@
     element = await fixture(html`<gr-repo></gr-repo>`);
   });
 
-  test('render', () => {
+  test('render', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    await element.updateComplete;
     // prettier and shadowDom assert do not agree about span.title wrapping
     assert.shadowDom.equal(
       element,
       /* prettier-ignore */ /* HTML */ `
       <div class="gr-form-styles main read-only">
         <div class="info">
-          <h1 class="heading-1" id="Title"></h1>
+          <h1 class="heading-1" id="Title">test-repo</h1>
           <hr />
           <div>
             <a href="">
@@ -178,7 +181,7 @@
                 Browse
               </gr-button>
             </a>
-            <a href="">
+            <a href="/q/project:test-repo">
               <gr-button
                 aria-disabled="false"
                 link=""
@@ -190,15 +193,7 @@
             </a>
           </div>
         </div>
-        <div class="loading" id="loading">Loading...</div>
-        <div class="loading" id="loadedContent">
-          <div class="hide" id="downloadContent">
-            <h2 class="heading-2" id="download">Download</h2>
-            <fieldset>
-              <gr-download-commands id="downloadCommands">
-              </gr-download-commands>
-            </fieldset>
-          </div>
+        <div id="loadedContent">
           <h2 class="heading-2" id="configurations">Configurations</h2>
           <div id="form">
             <fieldset>
@@ -266,7 +261,7 @@
                   </span>
                 </section>
                 <section
-                  class="repositorySettings"
+                  class="repositorySettings showConfig"
                   id="enableSignedPushSettings"
                 >
                   <span class="title"> Enable signed push </span>
@@ -277,7 +272,7 @@
                   </span>
                 </section>
                 <section
-                  class="repositorySettings"
+                  class="repositorySettings showConfig"
                   id="requireSignedPushSettings"
                 >
                   <span class="title"> Require signed push </span>
@@ -379,9 +374,6 @@
                   </span>
                 </section>
               </fieldset>
-              <div class="hide pluginConfig">
-                <h3 class="heading-3">Plugins</h3>
-              </div>
               <gr-button
                 aria-disabled="true"
                 disabled=""
@@ -398,7 +390,51 @@
           </div>
         </div>
       </div>
-    `
+    `,
+      {ignoreTags: ['option']}
+    );
+  });
+
+  test('render loading', async () => {
+    element.repo = REPO as RepoName;
+    element.loading = true;
+    await element.updateComplete;
+    // prettier and shadowDom assert do not agree about span.title wrapping
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <div class="gr-form-styles main read-only">
+        <div class="info">
+          <h1 class="heading-1" id="Title">test-repo</h1>
+          <hr />
+          <div>
+            <a href="">
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                link=""
+                role="button"
+                tabindex="-1"
+              >
+                Browse
+              </gr-button>
+            </a>
+            <a href="/q/project:test-repo">
+              <gr-button
+                aria-disabled="false"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Changes
+              </gr-button>
+            </a>
+          </div>
+        </div>
+        <div id="loading">Loading...</div>
+      </div>
+    `,
+      {ignoreTags: ['option']}
     );
   });
 
@@ -451,55 +487,22 @@
     assert.isTrue(requestUpdateStub.called);
   });
 
-  test('loading displays before repo config is loaded', () => {
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
-        'loading'
-      )
-    );
-    assert.isFalse(
-      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '#loading'))
-        .display === 'none'
-    );
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#loadedContent'
-      ).classList.contains('loading')
-    );
-    assert.isTrue(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#loadedContent')
-      ).display === 'none'
-    );
-  });
-
-  test('download commands visibility', async () => {
-    element.loading = false;
-    await element.updateComplete;
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#downloadContent'
-      ).classList.contains('hide')
-    );
-    assert.isTrue(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
-      ).display === 'none'
-    );
+  test('render download commands', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
     element.schemesObj = SCHEMES;
     await element.updateComplete;
-    assert.isFalse(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#downloadContent'
-      ).classList.contains('hide')
-    );
-    assert.isFalse(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
-      ).display === 'none'
+    const content = queryAndAssert<HTMLDivElement>(element, '#downloadContent');
+    assert.dom.equal(
+      content,
+      /* HTML */ `
+        <div id="downloadContent">
+          <h2 class="heading-2" id="download">Download</h2>
+          <fieldset>
+            <gr-download-commands id="downloadCommands"></gr-download-commands>
+          </fieldset>
+        </div>
+      `
     );
   });
 
@@ -715,9 +718,9 @@
         Promise.resolve(new Response())
       );
 
-      const button = queryAll<GrButton>(element, 'gr-button')[2];
-
       await element.loadRepo();
+
+      const button = queryAll<GrButton>(element, 'gr-button')[2];
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(
         queryAndAssert<HTMLHeadingElement>(
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 975dd3b..5f0a171 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -343,7 +343,7 @@
   // private but used in test
   computeGroupPath(groupId?: string) {
     if (!groupId) return;
-    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId, true)}`;
+    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId)}`;
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index faaee0b..d2ba2c9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -6,7 +6,6 @@
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   AccountDetailInfo,
   AccountId,
@@ -28,6 +27,7 @@
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
 import {userModelToken} from '../../../models/user/user-model';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
@@ -81,6 +81,8 @@
 
   private readonly getViewModel = resolve(this, searchViewModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.addEventListener('next-page', () => this.handleNextPage());
@@ -282,15 +284,13 @@
   // private but used in test
   handleNextPage() {
     if (!this.nextArrow || !this.changesPerPage) return;
-    // TODO: Use navigation service instead of `page.show()` directly.
-    page.show(this.computeNavLink(1));
+    this.getNavigation().setUrl(this.computeNavLink(1));
   }
 
   // private but used in test
   handlePreviousPage() {
     if (!this.prevArrow || !this.changesPerPage) return;
-    // TODO: Use navigation service instead of `page.show()` directly.
-    page.show(this.computeNavLink(-1));
+    this.getNavigation().setUrl(this.computeNavLink(-1));
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index f4bd8bd..decc253 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-change-list-view';
 import {GrChangeListView} from './gr-change-list-view';
-import {page} from '../../../utils/page-wrapper-utils';
 import {query, queryAndAssert} from '../../../test/test-utils';
 import {createChange} from '../../../test/test-data-generators';
 import {ChangeInfo} from '../../../api/rest-api';
@@ -14,6 +13,8 @@
 import {GrChangeList} from '../gr-change-list/gr-change-list';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-change-list-view tests', () => {
   let element: GrChangeListView;
@@ -158,7 +159,7 @@
   });
 
   test('handleNextPage', async () => {
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     element.changes = Array(25)
       .fill(0)
       .map(_ => createChange());
@@ -166,7 +167,7 @@
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
 
     element.changes = Array(25)
       .fill(0)
@@ -174,11 +175,11 @@
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
-    assert.isTrue(showStub.called);
+    assert.isTrue(setUrlStub.called);
   });
 
   test('handlePreviousPage', async () => {
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     element.offset = 0;
     element.changes = Array(25)
       .fill(0)
@@ -187,11 +188,11 @@
     element.loading = false;
     await element.updateComplete;
     element.handlePreviousPage();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
 
     element.offset = 25;
     await element.updateComplete;
     element.handlePreviousPage();
-    assert.isTrue(showStub.called);
+    assert.isTrue(setUrlStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 84bdffb..b726292 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -55,7 +55,6 @@
 
 import {SummaryChipStyles} from './gr-summary-chip';
 import {when} from 'lit/directives/when.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {combineLatest} from 'rxjs';
 import {userModelToken} from '../../../models/user/user-model';
 
@@ -120,8 +119,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     subscribe(
@@ -174,24 +171,22 @@
       () => this.getUserModel().account$,
       x => (this.selfAccount = x)
     );
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      subscribe(
-        this,
-        () =>
-          combineLatest([
-            this.getUserModel().account$,
-            this.getCommentsModel().threads$,
-          ]),
-        ([selfAccount, threads]) => {
-          if (!selfAccount || !selfAccount.email) return;
-          const unresolvedThreadsMentioningSelf = getMentionedThreads(
-            threads,
-            selfAccount
-          ).filter(isUnresolved);
-          this.mentionCount = unresolvedThreadsMentioningSelf.length;
-        }
-      );
-    }
+    subscribe(
+      this,
+      () =>
+        combineLatest([
+          this.getUserModel().account$,
+          this.getCommentsModel().threads$,
+        ]),
+      ([selfAccount, threads]) => {
+        if (!selfAccount || !selfAccount.email) return;
+        const unresolvedThreadsMentioningSelf = getMentionedThreads(
+          threads,
+          selfAccount
+        ).filter(isUnresolved);
+        this.mentionCount = unresolvedThreadsMentioningSelf.length;
+      }
+    );
   }
 
   static override get styles() {
@@ -575,8 +570,6 @@
   }
 
   private renderMentionChip() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return nothing;
     if (!this.mentionCount) return nothing;
     return html` <gr-summary-chip
       class="mentionSummary"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index c0ed3b3..d4627e2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -1256,7 +1256,14 @@
         flatten
         down-arrow
         class="showCopyLinkDialogButton"
-        @click=${() => this.copyLinksDropdown?.toggleDropdown()}
+        @click=${(e: MouseEvent) => {
+          // We don't want to handle clicks on the star or the <a> link.
+          // Calling `stopPropagation()` from the click handler of <a> is not an
+          // option, because then the click does not reach the top-level page.js
+          // click handler and would result is a full page reload.
+          if ((e.target as HTMLElement)?.nodeName !== 'GR-BUTTON') return;
+          this.copyLinksDropdown?.toggleDropdown();
+        }}
         ><gr-change-star
           id="changeStar"
           .change=${this.change}
@@ -1267,10 +1274,7 @@
         <a
           class="changeNumber"
           aria-label=${`Change ${this.change?._number}`}
-          @click=${(e: MouseEvent) => {
-            fireReload(this, true);
-            e.stopPropagation();
-          }}
+          href=${ifDefined(this.computeChangeUrl(true))}
           >${this.change?._number}</a
         >
       </gr-button>
@@ -2075,9 +2079,7 @@
 
   // Private but used in tests.
   viewStateChanged() {
-    // viewState is set by gr-router in handleChangeRoute method and is never
-    // set to undefined
-    assertIsDefined(this.viewState, 'viewState');
+    if (!this.viewState) return;
 
     if (this.isChangeObsolete()) {
       // Tell the app element that we are not going to handle the new change
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index b0dbda5..6f8bd9a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -71,6 +71,9 @@
   text = '';
 
   @state()
+  shouldRebaseChain = false;
+
+  @state()
   private query: AutocompleteQuery;
 
   @state()
@@ -184,7 +187,7 @@
             />
             <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
               Rebase on top of the ${this.branch} branch<span
-                ?hidden=${!this.hasParent}
+                ?hidden=${!this.hasParent || this.shouldRebaseChain}
               >
                 (breaks relation chain)
               </span>
@@ -206,7 +209,9 @@
             />
             <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
               Rebase on a specific change, ref, or commit
-              <span ?hidden=${!this.hasParent}> (breaks relation chain) </span>
+              <span ?hidden=${!this.hasParent || this.shouldRebaseChain}>
+                (breaks relation chain)
+              </span>
             </label>
           </div>
           <div class="parentRevisionContainer">
@@ -230,10 +235,17 @@
             >
           </div>
           ${when(
-            this.flagsService.isEnabled(KnownExperimentId.REBASE_CHAIN),
+            this.flagsService.isEnabled(KnownExperimentId.REBASE_CHAIN) &&
+              this.hasParent,
             () =>
               html`<div>
-                <input id="rebaseChain" type="checkbox" />
+                <input
+                  id="rebaseChain"
+                  type="checkbox"
+                  @change=${() => {
+                    this.shouldRebaseChain = !!this.rebaseChain?.checked;
+                  }}
+                />
                 <label for="rebaseChain">Rebase all ancestors</label>
               </div>`
           )}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index d4cff78..218594b 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -121,7 +121,6 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {
   CommentEditingChangedDetail,
@@ -384,8 +383,6 @@
   private readonly restApiService: RestApiService =
     getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -671,9 +668,6 @@
       this,
       () => this.getCommentsModel().mentionedUsersInUnresolvedDrafts$,
       x => {
-        if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-          return;
-        }
         this.mentionedUsersInUnresolvedDrafts = x.filter(
           v => !this.isAlreadyReviewerOrCC(v)
         );
@@ -1441,18 +1435,13 @@
     ).filter(isDefined);
 
     for (const user of newAttentionSetUsers) {
-      let reason;
-      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-        reason =
-          getMentionedReason(
-            this.draftCommentThreads,
-            this.account,
-            user,
-            this.serverConfig
-          ) ?? '';
-      } else {
-        reason = getReplyByReason(this.account, this.serverConfig);
-      }
+      const reason =
+        getMentionedReason(
+          this.draftCommentThreads,
+          this.account,
+          user,
+          this.serverConfig
+        ) ?? '';
       reviewInput.add_to_attention_set.push({user: getUserId(user), reason});
     }
     reviewInput.remove_from_attention_set = [];
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 12b1c40..f7b3aec 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -13,7 +13,6 @@
   query,
   queryAll,
   queryAndAssert,
-  stubFlags,
   stubRestApi,
   waitUntilVisible,
 } from '../../../test/test-utils';
@@ -59,7 +58,6 @@
 import {accountKey} from '../../../utils/account-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {Key, Modifier} from '../../../utils/dom-util';
 import {GrComment} from '../../shared/gr-comment/gr-comment';
 import {testResolver} from '../../../test/common-test-setup';
@@ -2527,9 +2525,6 @@
 
   suite('mention users', () => {
     setup(async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
       element.account = createAccountWithId(1);
       element.requestUpdate();
       await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 80a1a9a..c09d2a2 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -42,7 +42,6 @@
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
 import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {HtmlPatched} from '../../../utils/lit-util';
 import {userModelToken} from '../../../models/user/user-model';
 import {specialFilePathCompare} from '../../../utils/path-list-util';
@@ -205,8 +204,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly patched = new HtmlPatched(key => {
@@ -495,14 +492,10 @@
       value: CommentTabState.UNRESOLVED,
     });
     if (this.account) {
-      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-        items.push({
-          text: `Mentions (${
-            getMentionedThreads(threads, this.account).length
-          })`,
-          value: CommentTabState.MENTIONS,
-        });
-      }
+      items.push({
+        text: `Mentions (${getMentionedThreads(threads, this.account).length})`,
+        value: CommentTabState.MENTIONS,
+      });
       items.push({
         text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index 0a21da4..efc6efe 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -8,11 +8,21 @@
 import {LitElement, css, html, PropertyValues, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {RunResult} from '../../models/checks/checks-model';
-import {createFixAction, iconFor} from '../../models/checks/checks-util';
+import {
+  createFixAction,
+  createPleaseFixComment,
+  iconFor,
+} from '../../models/checks/checks-util';
 import {modifierPressed} from '../../utils/dom-util';
 import './gr-checks-results';
 import './gr-hovercard-run';
 import {fontStyles} from '../../styles/gr-font-styles';
+import {Action} from '../../api/checks';
+import {assertIsDefined} from '../../utils/common-util';
+import {resolve} from '../../models/dependency';
+import {commentsModelToken} from '../../models/comments/comments-model';
+import {subscribe} from '../lit/subscription-controller';
+import {changeModelToken} from '../../models/change/change-model';
 
 @customElement('gr-diff-check-result')
 export class GrDiffCheckResult extends LitElement {
@@ -32,6 +42,13 @@
   @state()
   isExpandable = false;
 
+  @state()
+  isOwner = false;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
   static override get styles() {
     return [
       fontStyles,
@@ -114,6 +131,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+  }
+
   override render() {
     if (!this.result) return;
     const cat = this.result.category.toLowerCase();
@@ -182,14 +208,39 @@
 
   private renderActions() {
     if (!this.isExpanded) return nothing;
-    return html`<div class="actions">${this.renderFixButton()}</div>`;
+    return html`<div class="actions">
+      ${this.renderPleaseFixButton()}${this.renderShowFixButton()}
+    </div>`;
   }
 
-  private renderFixButton() {
+  private renderPleaseFixButton() {
+    if (this.isOwner) return nothing;
+    const action: Action = {
+      name: 'Please Fix',
+      callback: () => {
+        assertIsDefined(this.result, 'result');
+        this.getCommentsModel().saveDraft(createPleaseFixComment(this.result));
+        return undefined;
+      },
+    };
+    return html`
+      <gr-checks-action
+        id="please-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
+    `;
+  }
+
+  private renderShowFixButton() {
     const action = createFixAction(this, this.result);
     if (!action) return nothing;
     return html`
-      <gr-checks-action context="diff-fix" .action=${action}></gr-checks-action>
+      <gr-checks-action
+        id="show-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 3892c9a..0377e0e 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -7,6 +7,7 @@
 import {fakeRun1} from '../../models/checks/checks-fakes';
 import {RunResult} from '../../models/checks/checks-model';
 import '../../test/common-test-setup';
+import {queryAndAssert} from '../../utils/common-util';
 import './gr-diff-check-result';
 import {GrDiffCheckResult} from './gr-diff-check-result';
 
@@ -50,4 +51,30 @@
     `
     );
   });
+
+  test('renders expanded', async () => {
+    element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult;
+    element.isExpanded = true;
+    await element.updateComplete;
+
+    const details = queryAndAssert(element, 'div.details');
+    assert.dom.equal(
+      details,
+      /* HTML */ `
+        <div class="details">
+          <gr-result-expanded hidecodepointers=""></gr-result-expanded>
+          <div class="actions">
+            <gr-checks-action
+              id="please-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+            <gr-checks-action
+              id="show-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+          </div>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index bcf6937..264560b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {
+  Options,
   page,
   PageContext,
   PageNextCallback,
@@ -34,6 +35,7 @@
 import {fireAlert, firePageError} from '../../../utils/event-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
+  encodeURL,
   getBaseUrl,
   PatchRangeParams,
   toPath,
@@ -49,6 +51,7 @@
   AdminChildView,
   AdminViewModel,
   AdminViewState,
+  PLUGIN_LIST_ROUTE,
 } from '../../../models/views/admin';
 import {
   AgreementViewModel,
@@ -99,7 +102,11 @@
   isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
 import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {Route, ViewState} from '../../../models/views/base';
+import {Model} from '../../../models/model';
 
+// TODO: Move all patterns to view model files and use the `Route` interface,
+// which will enforce using `RegExp` in its `urlPattern` property.
 const RoutePattern = {
   ROOT: '/',
 
@@ -114,7 +121,9 @@
 
   // Pattern for login and logout URLs intended to be passed-through. May
   // include a return URL.
-  LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+  // TODO: Maybe this pattern and its handler can just be removed, because
+  // passing through is what the default router would eventually do anyway.
+  LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
 
   // Pattern for a catchall route when no other pattern is matched.
   DEFAULT: /.*/,
@@ -135,11 +144,6 @@
   // Matches /admin/groups/[uuid-]<group>,members
   GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
 
-  // Matches /admin/groups[,<offset>][/].
-  GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
-  GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
-  GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
-
   // Matches /admin/create-project
   LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
 
@@ -166,30 +170,24 @@
   // Matches /admin/repos/<repos>,access.
   REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-  // Matches /admin/repos[,<offset>][/].
-  REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
-  REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
-  REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
-
-  // Matches /admin/repos/<repo>,branches[,<offset>].
-  BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
-  BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
-  BRANCH_LIST_FILTER_OFFSET:
-    '/admin/repos/:repo,branches/q/filter::filter,:offset',
-
-  // Matches /admin/repos/<repo>,tags[,<offset>].
-  TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
-  TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
-  TAG_LIST_FILTER_OFFSET: '/admin/repos/:repo,tags/q/filter::filter,:offset',
-
   PLUGINS: /^\/plugins\/(.+)$/,
 
-  PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
+  // TODO: The first capturing group in the next 3 patterns works around a bug
+  // in page.js that is fixed in version 1.11.6. Remove it when the new version
+  // is being used at Google.
+  // https://github.com/visionmedia/page.js/commit/60f764b0ca9ad55133bc373914e97a8927a8f2d5
 
-  // Matches /admin/plugins[,<offset>][/].
-  PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
-  PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
-  PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
+  // Matches /admin/plugins with optional filter and offset.
+  PLUGIN_LIST: /^(\/admin\/plugins)\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/groups with optional filter and offset.
+  GROUP_LIST: /^(\/admin\/groups)\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos with optional filter and offset.
+  REPO_LIST: /^(\/admin\/repos)\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos/$REPO,branches with optional filter and offset.
+  BRANCH_LIST:
+    /^\/admin\/repos\/(.+),branches\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos/$REPO,tags with optional filter and offset.
+  TAG_LIST: /^\/admin\/repos\/(.+),tags\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
 
   QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
 
@@ -251,13 +249,11 @@
 
   PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
 
-  DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+  DOCUMENTATION_SEARCH_FILTER: /^\/Documentation\/q\/filter:(.*)$/,
   DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
   DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
 };
 
-export const _testOnly_RoutePattern = RoutePattern;
-
 /**
  * Pattern to recognize and parse the diff line locations as they appear in
  * the hash of diff URLs. In this format, a number on its own indicates that
@@ -306,6 +302,8 @@
 
   private view?: GerritView;
 
+  readonly page = page.create();
+
   constructor(
     private readonly reporting: ReportingService,
     private readonly routerModel: RouterModel,
@@ -342,7 +340,7 @@
         }
 
         if (browserUrl.toString() !== stateUrl.toString()) {
-          page.replace(
+          this.page.replace(
             stateUrl.toString(),
             null,
             /* init: */ false,
@@ -359,6 +357,7 @@
       subscription.unsubscribe();
     }
     this.subscriptions = [];
+    this.page.stop();
   }
 
   start() {
@@ -369,6 +368,7 @@
   }
 
   setState(state: AppElementParams) {
+    // TODO: Move this logic into the change model.
     if ('repo' in state && state.repo !== undefined && 'changeNum' in state)
       this.restApiService.setInProjectLookup(state.changeNum, state.repo);
 
@@ -400,7 +400,7 @@
 
   redirect(url: string) {
     this._isRedirecting = true;
-    page.redirect(url);
+    this.page.redirect(url);
   }
 
   /**
@@ -429,7 +429,9 @@
    */
   redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
-    page('/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
+    this.page(
+      '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+    );
   }
 
   /**
@@ -488,6 +490,9 @@
    * route is matched, the handler will be executed with `this` referring
    * to the component. Its return value will be discarded so that it does
    * not interfere with page.js.
+   * TODO: Get rid of this parameter. This is really not something that the
+   * router wants to be concerned with. The reporting service and the view
+   * models should figure that out between themselves.
    * @param authRedirect If true, then auth is checked before
    * executing the handler. If the user is not logged in, it will redirect
    * to the login flow and the handler will not be executed. The login
@@ -499,7 +504,7 @@
     handler: (ctx: PageContext) => void,
     authRedirect?: boolean
   ) {
-    page(
+    this.page(
       pattern,
       (ctx, next) => this.loadUserMiddleware(ctx, next),
       ctx => {
@@ -515,6 +520,32 @@
   }
 
   /**
+   * Convenience wrapper of `mapRoute()` for when you have a `Route` object that
+   * can deal with state creation. Takes care of setting the view model state,
+   * which is currently duplicated lots of times for direct callers of
+   * `mapRoute()`.
+   */
+  mapRouteState<T extends ViewState>(
+    route: Route<T>,
+    viewModel: Model<T | undefined>,
+    handlerName: string,
+    authRedirect?: boolean
+  ) {
+    const handler = (ctx: PageContext) => {
+      const state = route.createState(ctx);
+      // Note that order is important: `this.setState()` must be called before
+      // `viewModel.setState()`. Otherwise the chain of model subscriptions
+      // would be very different. Some views may want app element to swap the
+      // top level view first. Also, `this.setState()` has some special change
+      // view model resetting logic. Eventually the order might not be important
+      // anymore, but be careful! :-)
+      this.setState(state as AppElementParams);
+      viewModel.setState(state);
+    };
+    this.mapRoute(route.urlPattern, handlerName, handler, authRedirect);
+  }
+
+  /**
    * This is similar to letting the browser navigate to this URL when the user
    * clicks it, or to just setting `window.location.href` directly.
    *
@@ -524,14 +555,14 @@
    * page.show() eventually just calls `window.history.pushState()`.
    */
   setUrl(url: string) {
-    page.show(url);
+    this.page.show(url);
   }
 
   /**
    * Navigate to this URL, but replace the current URL in the history instead of
    * adding a new one (which is what `setUrl()` would do).
    *
-   * page.redirect() eventually just calls `window.history.replaceState()`.
+   * this.page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string) {
     this.redirect(url);
@@ -551,13 +582,17 @@
     );
   }
 
-  startRouter() {
+  _testOnly_startRouter() {
+    this.startRouter({dispatch: false, popstate: false});
+  }
+
+  startRouter(opts: Options = {}) {
     const base = getBaseUrl();
     if (base) {
-      page.base(base);
+      this.page.base(base);
     }
 
-    page.exit('*', (_, next) => {
+    this.page.exit('*', (_, next) => {
       if (!this._isRedirecting) {
         this.reporting.beforeLocationChanged();
       }
@@ -568,7 +603,7 @@
 
     // Remove the tracking param 'usp' (User Source Parameter) from the URL,
     // just to have users look at cleaner URLs.
-    page((ctx, next) => {
+    this.page((ctx, next) => {
       if (window.URLSearchParams) {
         const pathname = toPathname(ctx.canonicalPath);
         const searchParams = toSearchParams(ctx.canonicalPath);
@@ -584,7 +619,7 @@
     });
 
     // Middleware
-    page((ctx, next) => {
+    this.page((ctx, next) => {
       document.body.scrollTop = 0;
 
       if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
@@ -651,23 +686,9 @@
     );
 
     this.mapRoute(
-      RoutePattern.GROUP_LIST_OFFSET,
-      'handleGroupListOffsetRoute',
-      ctx => this.handleGroupListOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.GROUP_LIST_FILTER_OFFSET,
-      'handleGroupListFilterOffsetRoute',
-      ctx => this.handleGroupListFilterOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.GROUP_LIST_FILTER,
-      'handleGroupListFilterRoute',
-      ctx => this.handleGroupListFilterRoute(ctx),
+      RoutePattern.GROUP_LIST,
+      'handleGroupListRoute',
+      ctx => this.handleGroupListRoute(ctx),
       true
     );
 
@@ -717,40 +738,12 @@
       ctx => this.handleRepoDashboardsRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_OFFSET,
-      'handleBranchListOffsetRoute',
-      ctx => this.handleBranchListOffsetRoute(ctx)
+    this.mapRoute(RoutePattern.BRANCH_LIST, 'handleBranchListRoute', ctx =>
+      this.handleBranchListRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_FILTER_OFFSET,
-      'handleBranchListFilterOffsetRoute',
-      ctx => this.handleBranchListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_FILTER,
-      'handleBranchListFilterRoute',
-      ctx => this.handleBranchListFilterRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_OFFSET,
-      'handleTagListOffsetRoute',
-      ctx => this.handleTagListOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_FILTER_OFFSET,
-      'handleTagListFilterOffsetRoute',
-      ctx => this.handleTagListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_FILTER,
-      'handleTagListFilterRoute',
-      ctx => this.handleTagListFilterRoute(ctx)
+    this.mapRoute(RoutePattern.TAG_LIST, 'handleTagListRoute', ctx =>
+      this.handleTagListRoute(ctx)
     );
 
     this.mapRoute(
@@ -767,22 +760,8 @@
       true
     );
 
-    this.mapRoute(
-      RoutePattern.REPO_LIST_OFFSET,
-      'handleRepoListOffsetRoute',
-      ctx => this.handleRepoListOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.REPO_LIST_FILTER_OFFSET,
-      'handleRepoListFilterOffsetRoute',
-      ctx => this.handleRepoListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.REPO_LIST_FILTER,
-      'handleRepoListFilterRoute',
-      ctx => this.handleRepoListFilterRoute(ctx)
+    this.mapRoute(RoutePattern.REPO_LIST, 'handleRepoListRoute', ctx =>
+      this.handleRepoListRoute(ctx)
     );
 
     this.mapRoute(RoutePattern.REPO, 'handleRepoRoute', ctx =>
@@ -794,30 +773,16 @@
     );
 
     this.mapRoute(
-      RoutePattern.PLUGIN_LIST_OFFSET,
-      'handlePluginListOffsetRoute',
-      ctx => this.handlePluginListOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
-      'handlePluginListFilterOffsetRoute',
-      ctx => this.handlePluginListFilterOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST_FILTER,
+      RoutePattern.PLUGIN_LIST,
       'handlePluginListFilterRoute',
       ctx => this.handlePluginListFilterRoute(ctx),
       true
     );
 
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST,
+    this.mapRouteState(
+      PLUGIN_LIST_ROUTE,
+      this.adminViewModel,
       'handlePluginListRoute',
-      ctx => this.handlePluginListRoute(ctx),
       true
     );
 
@@ -955,7 +920,7 @@
       this.handleDefaultRoute()
     );
 
-    page.start();
+    this.page.start(opts);
   }
 
   /**
@@ -1010,7 +975,7 @@
         if (ctx.params[0].toLowerCase() === 'self') {
           this.redirectToLogin(ctx.canonicalPath);
         } else {
-          this.redirect('/q/owner:' + encodeURIComponent(ctx.params[0]));
+          this.redirect('/q/owner:' + encodeURL(ctx.params[0]));
         }
       } else {
         const state: DashboardViewState = {
@@ -1118,36 +1083,14 @@
     this.groupViewModel.setState(state);
   }
 
-  handleGroupListOffsetRoute(ctx: PageContext) {
+  handleGroupListRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.GROUPS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-      openCreateModal: ctx.hash === 'create',
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleGroupListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.GROUPS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleGroupListFilterRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.GROUPS,
-      filter: ctx.params['filter'] || null,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
+      openCreateModal:
+        !ctx.params[1] && !ctx.params[2] && ctx.hash === 'create',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1236,112 +1179,40 @@
     this.reporting.setRepoName(repo);
   }
 
-  handleBranchListOffsetRoute(ctx: PageContext) {
+  handleBranchListRoute(ctx: PageContext) {
     const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: ctx.params[0] as RepoName,
-      offset: ctx.params[2] || 0,
-      filter: null,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
   }
 
-  handleBranchListFilterOffsetRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.BRANCHES,
-      repo: ctx.params['repo'] as RepoName,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleBranchListFilterRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.BRANCHES,
-      repo: ctx.params['repo'] as RepoName,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleTagListOffsetRoute(ctx: PageContext) {
+  handleTagListRoute(ctx: PageContext) {
     const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: ctx.params[0] as RepoName,
-      offset: ctx.params[2] || 0,
-      filter: null,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
   }
 
-  handleTagListFilterOffsetRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.TAGS,
-      repo: ctx.params['repo'] as RepoName,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleTagListFilterRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.TAGS,
-      repo: ctx.params['repo'] as RepoName,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleRepoListOffsetRoute(ctx: PageContext) {
+  handleRepoListRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.REPOS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-      openCreateModal: ctx.hash === 'create',
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleRepoListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.REPOS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleRepoListFilterRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.REPOS,
-      filter: ctx.params['filter'] || null,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
+      openCreateModal:
+        !ctx.params[1] && !ctx.params[2] && ctx.hash === 'create',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1364,45 +1235,12 @@
     this.redirect(ctx.path + ',general');
   }
 
-  handlePluginListOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handlePluginListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
   handlePluginListFilterRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handlePluginListRoute(_: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
+      offset: ctx.params[2] ?? '0',
+      filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1410,10 +1248,12 @@
   }
 
   handleQueryRoute(ctx: PageContext) {
-    const state: Partial<SearchViewState> = {
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
       offset: ctx.params[2],
+      loading: false,
+      changes: [],
     };
     // Note that router model view must be updated before view models.
     this.setState(state as AppElementParams);
@@ -1424,10 +1264,12 @@
     // TODO(pcc): This will need to indicate that this was a change ID query if
     // standard queries gain the ability to search places like commit messages
     // for change IDs.
-    const state: Partial<SearchViewState> = {
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
       offset: undefined,
+      loading: false,
+      changes: [],
     };
     // Note that router model view must be updated before view models.
     this.setState(state as AppElementParams);
@@ -1439,7 +1281,7 @@
   }
 
   handleChangeNumberLegacyRoute(ctx: PageContext) {
-    this.redirect('/c/' + encodeURIComponent(ctx.params[0]));
+    this.redirect('/c/' + ctx.params[0]);
   }
 
   handleChangeRoute(ctx: PageContext) {
@@ -1734,7 +1576,7 @@
   handleDocumentationSearchRoute(ctx: PageContext) {
     const state: DocumentationViewState = {
       view: GerritView.DOCUMENTATION_SEARCH,
-      filter: ctx.params['filter'] || null,
+      filter: ctx.params[0] ?? '',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1742,9 +1584,7 @@
   }
 
   handleDocumentationSearchRedirectRoute(ctx: PageContext) {
-    this.redirect(
-      '/Documentation/q/filter:' + encodeURIComponent(ctx.params[0])
-    );
+    this.redirect('/Documentation/q/filter:' + encodeURL(ctx.params[0]));
   }
 
   handleDocumentationRedirectRoute(ctx: PageContext) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index d8761bf..3ca7bdc 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -5,45 +5,62 @@
  */
 import '../../../test/common-test-setup';
 import './gr-router';
-import {page, PageContext} from '../../../utils/page-wrapper-utils';
+import {Page, PageContext} from '../../../utils/page-wrapper-utils';
 import {
   stubBaseUrl,
   stubRestApi,
   addListenerForTest,
-  waitEventLoop,
+  waitUntilCalled,
 } from '../../../test/test-utils';
-import {GrRouter, routerToken, _testOnly_RoutePattern} from './gr-router';
+import {GrRouter, routerToken} from './gr-router';
 import {GerritView} from '../../../services/router/router-model';
 import {
   BasePatchSetNum,
-  GroupId,
   NumericChangeId,
   PARENT,
   RepoName,
   RevisionPatchSetNum,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {AppElementParams} from '../../gr-app-types';
+import {AppElementJustRegisteredParams} from '../../gr-app-types';
 import {assert} from '@open-wc/testing';
-import {AdminChildView} from '../../../models/views/admin';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 import {RepoDetailView} from '../../../models/views/repo';
 import {GroupDetailView} from '../../../models/views/group';
-import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView} from '../../../models/views/change';
 import {PatchRangeParams} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {
+  createAdminPluginsViewState,
+  createAdminReposViewState,
+  createChangeViewState,
   createComment,
+  createDashboardViewState,
   createDiff,
+  createDiffViewState,
+  createEditViewState,
+  createGroupViewState,
   createParsedChange,
+  createRepoBranchesViewState,
+  createRepoTagsViewState,
+  createRepoViewState,
   createRevision,
+  createSearchViewState,
 } from '../../../test/test-data-generators';
 import {ParsedChangeInfo} from '../../../types/types';
+import {ViewState} from '../../../models/views/base';
 
 suite('gr-router tests', () => {
   let router: GrRouter;
+  let page: Page;
 
   setup(() => {
     router = testResolver(routerToken);
+    page = router.page;
+  });
+
+  teardown(async () => {
+    router.finalize();
   });
 
   test('getHashFromCanonicalPath', () => {
@@ -101,7 +118,7 @@
     });
   });
 
-  test('startRouter requires auth for the right handlers', () => {
+  test('startRouterForTesting requires auth for the right handlers', () => {
     // This test encodes the lists of route handler methods that gr-router
     // automatically checks for authentication before triggering.
 
@@ -118,7 +135,7 @@
           doesNotRequireAuth[methodName] = true;
         }
       });
-    router.startRouter();
+    router._testOnly_startRouter();
 
     const actualRequiresAuth = Object.keys(requiresAuth);
     actualRequiresAuth.sort();
@@ -133,16 +150,12 @@
       'handleDiffEditRoute',
       'handleGroupAuditLogRoute',
       'handleGroupInfoRoute',
-      'handleGroupListFilterOffsetRoute',
-      'handleGroupListFilterRoute',
-      'handleGroupListOffsetRoute',
+      'handleGroupListRoute',
       'handleGroupMembersRoute',
       'handleGroupRoute',
       'handleGroupSelfRedirectRoute',
       'handleNewAgreementsRoute',
-      'handlePluginListFilterOffsetRoute',
       'handlePluginListFilterRoute',
-      'handlePluginListOffsetRoute',
       'handlePluginListRoute',
       'handleRepoCommandsRoute',
       'handleRepoEditFileRoute',
@@ -152,9 +165,7 @@
     assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
 
     const unauthenticatedHandlers = [
-      'handleBranchListFilterOffsetRoute',
-      'handleBranchListFilterRoute',
-      'handleBranchListOffsetRoute',
+      'handleBranchListRoute',
       'handleChangeIdQueryRoute',
       'handleChangeNumberLegacyRoute',
       'handleChangeRoute',
@@ -175,16 +186,12 @@
       'handleRepoAccessRoute',
       'handleRepoDashboardsRoute',
       'handleRepoGeneralRoute',
-      'handleRepoListFilterOffsetRoute',
-      'handleRepoListFilterRoute',
-      'handleRepoListOffsetRoute',
+      'handleRepoListRoute',
       'handleRepoRoute',
       'handleQueryLegacySuffixRoute',
       'handleQueryRoute',
       'handleRegisterRoute',
-      'handleTagListFilterOffsetRoute',
-      'handleTagListFilterRoute',
-      'handleTagListOffsetRoute',
+      'handleTagListRoute',
       'handlePluginScreen',
     ];
 
@@ -276,17 +283,42 @@
     let redirectStub: sinon.SinonStub;
     let setStateStub: sinon.SinonStub;
     let handlePassThroughRoute: sinon.SinonStub;
+    let redirectToLoginStub: sinon.SinonStub;
 
-    // Simple route handlers are direct mappings from parsed route ctx to a
-    // new set of app.params. This test helper asserts that passing `ctx`
-    // into `methodName` results in setting the params specified in `params`.
-    function assertctxToParams(
-      ctx: PageContext,
-      methodName: string,
-      params: AppElementParams
+    async function checkUrlToState<T extends ViewState>(
+      url: string,
+      state: T | AppElementJustRegisteredParams
     ) {
-      (router as any)[methodName](ctx);
-      assert.deepEqual(setStateStub.lastCall.args[0], params);
+      setStateStub.reset();
+      router.page.show(url);
+      await waitUntilCalled(setStateStub, 'setState');
+      assert.isTrue(setStateStub.calledOnce);
+      assert.deepEqual(setStateStub.lastCall.firstArg, state);
+    }
+
+    async function checkRedirect(fromUrl: string, toUrl: string) {
+      redirectStub.reset();
+      router.page.show(fromUrl);
+      await waitUntilCalled(redirectStub, 'redirect');
+      assert.isTrue(redirectStub.calledOnce);
+      assert.isFalse(setStateStub.called);
+      assert.equal(redirectStub.lastCall.firstArg, toUrl);
+    }
+
+    async function checkRedirectToLogin(fromUrl: string, toUrl: string) {
+      redirectToLoginStub.reset();
+      router.page.show(fromUrl);
+      await waitUntilCalled(redirectToLoginStub, 'redirectToLogin');
+      assert.isTrue(redirectToLoginStub.calledOnce);
+      assert.isFalse(redirectStub.called);
+      assert.isFalse(setStateStub.called);
+      assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
+    }
+
+    async function checkUrlNotMatched(url: string) {
+      handlePassThroughRoute.reset();
+      router.page.show(url);
+      await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
     }
 
     function createPageContext(): PageContext {
@@ -301,56 +333,49 @@
     }
 
     setup(() => {
+      stubRestApi('setInProjectLookup');
       redirectStub = sinon.stub(router, 'redirect');
+      redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       setStateStub = sinon.stub(router, 'setState');
       handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+      router._testOnly_startRouter();
     });
 
-    test('handleLegacyProjectDashboardRoute', () => {
-      const params = {
-        ...createPageContext(),
-        params: {0: 'gerrit/project', 1: 'dashboard:main'},
-      };
-      router.handleLegacyProjectDashboardRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(
-        redirectStub.lastCall.args[0],
+    test('LEGACY_PROJECT_DASHBOARD', async () => {
+      // LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
+      await checkRedirect(
+        '/projects/gerrit/project,dashboards/dashboard:main',
         '/p/gerrit/project/+/dashboard/dashboard:main'
       );
     });
 
-    test('handleAgreementsRoute', () => {
-      router.handleAgreementsRoute();
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+    test('AGREEMENTS', async () => {
+      // AGREEMENTS: /^\/settings\/agreements\/?/,
+      await checkRedirect('/settings/agreements', '/settings/#Agreements');
     });
 
-    test('handleNewAgreementsRoute', () => {
-      router.handleNewAgreementsRoute();
-      assert.isTrue(setStateStub.calledOnce);
-      assert.equal(setStateStub.lastCall.args[0].view, GerritView.AGREEMENTS);
-    });
-
-    test('handleSettingsLegacyRoute', () => {
-      const ctx = {...createPageContext(), params: {0: 'my-token'}};
-      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
-        view: GerritView.SETTINGS,
-        emailToken: 'my-token',
+    test('NEW_AGREEMENTS', async () => {
+      // NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
+      await checkUrlToState('/settings/new-agreement', {
+        view: GerritView.AGREEMENTS,
+      });
+      await checkUrlToState('/settings/new-agreement/', {
+        view: GerritView.AGREEMENTS,
       });
     });
 
-    test('handleSettingsLegacyRoute with +', () => {
-      const ctx = {...createPageContext(), params: {0: 'my-token test'}};
-      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
+    test('SETTINGS', async () => {
+      // SETTINGS: /^\/settings\/?/,
+      // SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
+      await checkUrlToState('/settings', {view: GerritView.SETTINGS});
+      await checkUrlToState('/settings/', {view: GerritView.SETTINGS});
+      await checkUrlToState('/settings/VE/asdf', {
         view: GerritView.SETTINGS,
-        emailToken: 'my-token+test',
+        emailToken: 'asdf',
       });
-    });
-
-    test('handleSettingsRoute', () => {
-      const ctx = createPageContext();
-      assertctxToParams(ctx, 'handleSettingsRoute', {
+      await checkUrlToState('/settings/VE/asdf%40qwer', {
         view: GerritView.SETTINGS,
+        emailToken: 'asdf@qwer',
       });
     });
 
@@ -373,7 +398,7 @@
       sinon.stub(page, 'exit').callsFake(onRegisteringExit);
       sinon.stub(page, 'start');
       sinon.stub(page, 'base');
-      router.startRouter();
+      router._testOnly_startRouter();
 
       router.handleDefaultRoute();
 
@@ -383,88 +408,62 @@
       assert.isTrue(handlePassThroughRoute.calledOnce);
     });
 
-    test('handleImproperlyEncodedPlusRoute', () => {
-      const params = {
-        ...createPageContext(),
-        canonicalPath: '/c/test/%20/42',
-        params: {0: 'test', 1: '42'},
-      };
-      // Regression test for Issue 7100.
-      router.handleImproperlyEncodedPlusRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42');
-
-      sinon.stub(router, 'getHashFromCanonicalPath').returns('foo');
-      router.handleImproperlyEncodedPlusRoute(params);
-      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42#foo');
+    test('IMPROPERLY_ENCODED_PLUS', async () => {
+      // IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/ \/(.+)$/,
+      await checkRedirect('/c/repo/ /42', '/c/repo/+/42');
+      await checkRedirect('/c/repo/%20/42', '/c/repo/+/42');
+      await checkRedirect('/c/repo/ /42#foo', '/c/repo/+/42#foo');
     });
 
-    test('handleQueryRoute', () => {
-      const ctx: PageContext = {
-        ...createPageContext(),
-        params: {0: 'project:foo/bar/baz'},
-      };
-      assertctxToParams(ctx, 'handleQueryRoute', {
-        view: GerritView.SEARCH,
+    test('QUERY', async () => {
+      // QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+      await checkUrlToState('/q/asdf', {
+        ...createSearchViewState(),
+        query: 'asdf',
+      });
+      await checkUrlToState('/q/project:foo/bar/baz', {
+        ...createSearchViewState(),
         query: 'project:foo/bar/baz',
-        offset: undefined,
-      } as AppElementParams);
-
-      ctx.params[1] = '123';
-      ctx.params[2] = '123';
-      assertctxToParams(ctx, 'handleQueryRoute', {
-        view: GerritView.SEARCH,
-        query: 'project:foo/bar/baz',
+      });
+      await checkUrlToState('/q/asdf,123', {
+        ...createSearchViewState(),
+        query: 'asdf',
         offset: '123',
-      } as AppElementParams);
+      });
     });
 
-    test('handleQueryLegacySuffixRoute', () => {
-      const params = {...createPageContext(), path: '/q/foo+bar,n,z'};
-      router.handleQueryLegacySuffixRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+    test('QUERY_LEGACY_SUFFIX', async () => {
+      // QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
+      await checkRedirect('/q/foo+bar,n,z', '/q/foo+bar');
     });
 
-    test('handleChangeIdQueryRoute', () => {
-      const ctx = {
-        ...createPageContext(),
-        params: {0: 'I0123456789abcdef0123456789abcdef01234567'},
-      };
-      assertctxToParams(ctx, 'handleChangeIdQueryRoute', {
-        view: GerritView.SEARCH,
+    test('CHANGE_ID_QUERY', async () => {
+      // CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
+      await checkUrlToState('/id/I0123456789abcdef0123456789abcdef01234567', {
+        ...createSearchViewState(),
         query: 'I0123456789abcdef0123456789abcdef01234567',
-        offset: undefined,
-      } as AppElementParams);
-    });
-
-    suite('handleRegisterRoute', () => {
-      test('happy path', () => {
-        const ctx = {...createPageContext(), params: {0: '/foo/bar'}};
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
-      });
-
-      test('no param', () => {
-        const ctx = createPageContext();
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
-      });
-
-      test('prevent redirect', () => {
-        const ctx = {...createPageContext(), params: {0: '/register'}};
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
       });
     });
 
-    suite('handleRootRoute', () => {
+    test('REGISTER', async () => {
+      // REGISTER: /^\/register(\/.*)?$/,
+      await checkUrlToState('/register/foo/bar', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+
+      await checkUrlToState('/register', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/'));
+
+      await checkUrlToState('/register/register', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/'));
+    });
+
+    suite('ROOT', () => {
       test('closes for closeAfterLogin', () => {
         const ctx = {...createPageContext(), querystring: 'closeAfterLogin'};
         const closeStub = sinon.stub(window, 'close');
@@ -474,737 +473,413 @@
         assert.isFalse(redirectStub.called);
       });
 
-      test('redirects to dashboard if logged in', () => {
-        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
-        const result = router.handleRootRoute(ctx);
-        assert.isOk(result);
-        return result!.then(() => {
-          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
-        });
+      test('ROOT logged in', async () => {
+        stubRestApi('getLoggedIn').resolves(true);
+        await checkRedirect('/', '/dashboard/self');
       });
 
-      test('redirects to open changes if not logged in', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
-        const result = router.handleRootRoute(ctx);
-        assert.isOk(result);
-        return result!.then(() => {
-          assert.isTrue(
-            redirectStub.calledWithExactly('/q/status:open+-is:wip')
-          );
-        });
+      test('ROOT not logged in', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirect('/', '/q/status:open+-is:wip');
       });
 
-      suite('GWT hash-path URLs', () => {
-        test('redirects hash-path URLs', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar/baz',
-            hash: '/foo/bar/baz',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+      suite('ROOT GWT hash-path URLs', () => {
+        test('ROOT hash-path URLs', async () => {
+          await checkRedirect('/#/foo/bar/baz', '/foo/bar/baz');
         });
 
-        test('redirects hash-path URLs w/o leading slash', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#foo/bar/baz',
-            hash: 'foo/bar/baz',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        test('ROOT hash-path URLs w/o leading slash', async () => {
+          await checkRedirect('/#foo/bar/baz', '/foo/bar/baz');
         });
 
-        test('normalizes "/ /" in hash to "/+/"', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar/+/123/4',
-            hash: '/foo/bar/ /123/4',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+        test('ROOT normalizes "/ /" in hash to "/+/"', async () => {
+          await checkRedirect('/#/foo/bar/+/123/4', '/foo/bar/+/123/4');
         });
 
-        test('prepends baseurl to hash-path', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar',
-            hash: '/foo/bar',
-          };
+        test('ROOT prepends baseurl to hash-path', async () => {
           stubBaseUrl('/baz');
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+          await checkRedirect('/#/foo/bar', '/baz/foo/bar');
         });
 
-        test('normalizes /VE/ settings hash-paths', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/VE/foo/bar',
-            hash: '/VE/foo/bar',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/settings/VE/foo/bar'));
+        test('ROOT normalizes /VE/ settings hash-paths', async () => {
+          await checkRedirect('/#/VE/foo/bar', '/settings/VE/foo/bar');
         });
 
-        test('does not drop "inner hashes"', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar#baz',
-            hash: '/foo/bar',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+        test('ROOT does not drop "inner hashes"', async () => {
+          await checkRedirect('/#/foo/bar#baz', '/foo/bar#baz');
         });
       });
     });
 
-    suite('handleDashboardRoute', () => {
-      let redirectToLoginStub: sinon.SinonStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+    suite('DASHBOARD', () => {
+      test('DASHBOARD own dashboard but signed out redirects to login', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirectToLogin('/dashboard/seLF', '/dashboard/seLF');
       });
 
-      test('own dashboard but signed out redirects to login', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'seLF'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isTrue(redirectToLoginStub.calledOnce);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(setStateStub.called);
-        });
+      test('DASHBOARD non-self dashboard but signed out redirects', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirect('/dashboard/foo', '/q/owner:foo');
       });
 
-      test('non-self dashboard but signed out does not redirect', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'foo'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(setStateStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
-        });
-      });
-
-      test('dashboard while signed in sets params', () => {
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'foo'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'foo',
-          });
+      test('DASHBOARD', async () => {
+        // DASHBOARD: /^\/dashboard\/(.+)$/,
+        await checkUrlToState('/dashboard/foo', {
+          ...createDashboardViewState(),
+          user: 'foo',
         });
       });
     });
 
-    suite('handleCustomDashboardRoute', () => {
-      let redirectToLoginStub: sinon.SinonStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+    suite('CUSTOM_DASHBOARD', () => {
+      test('CUSTOM_DASHBOARD no user specified', async () => {
+        await checkRedirect('/dashboard/', '/dashboard/self');
       });
 
-      test('no user specified', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(setStateStub.called);
-          assert.isTrue(redirectStub.called);
-          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+      test('CUSTOM_DASHBOARD', async () => {
+        // CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+        await checkUrlToState('/dashboard?title=Custom Dashboard&a=b&d=e', {
+          ...createDashboardViewState(),
+          sections: [
+            {name: 'a', query: 'b'},
+            {name: 'd', query: 'e'},
+          ],
+          title: 'Custom Dashboard',
         });
-      });
-
-      test('custom dashboard without title', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=e',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [
-              {name: 'a', query: 'b'},
-              {name: 'd', query: 'e'},
-            ],
-            title: 'Custom Dashboard',
-          });
-        });
-      });
-
-      test('custom dashboard with title', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=&=e&title=t',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [{name: 'a', query: 'b'}],
-            title: 't',
-          });
-        });
-      });
-
-      test('custom dashboard with foreach', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=&=e&foreach=is:open',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [{name: 'a', query: 'is:open b'}],
-            title: 'Custom Dashboard',
-          });
+        await checkUrlToState('/dashboard?a=b&c&d=&=e&foreach=is:open', {
+          ...createDashboardViewState(),
+          sections: [{name: 'a', query: 'is:open b'}],
+          title: 'Custom Dashboard',
         });
       });
     });
 
     suite('group routes', () => {
-      test('handleGroupInfoRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        router.handleGroupInfoRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+      test('GROUP_INFO', async () => {
+        // GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
+        await checkRedirect('/admin/groups/1234,info', '/admin/groups/1234');
       });
 
-      test('handleGroupAuditLogRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        assertctxToParams(ctx, 'handleGroupAuditLogRoute', {
-          view: GerritView.GROUP,
+      test('GROUP_AUDIT_LOG', async () => {
+        // GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+        await checkUrlToState('/admin/groups/1234,audit-log', {
+          ...createGroupViewState(),
           detail: GroupDetailView.LOG,
-          groupId: '1234' as GroupId,
+          groupId: '1234',
         });
       });
 
-      test('handleGroupMembersRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        assertctxToParams(ctx, 'handleGroupMembersRoute', {
-          view: GerritView.GROUP,
+      test('GROUP_MEMBERS', async () => {
+        // GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+        await checkUrlToState('/admin/groups/1234,members', {
+          ...createGroupViewState(),
           detail: GroupDetailView.MEMBERS,
-          groupId: '1234' as GroupId,
+          groupId: '1234',
         });
       });
 
-      test('handleGroupListOffsetRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          offset: 0,
-          filter: null,
-          openCreateModal: false,
-        });
+      test('GROUP_LIST', async () => {
+        // GROUP_LIST: /^\/admin\/groups(\/q\/filter:(.*?))?(,(\d+))?(\/)?$/,
 
-        ctx.params[1] = '42';
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
+        const defaultState: AdminViewState = {
           view: GerritView.ADMIN,
           adminView: AdminChildView.GROUPS,
-          offset: '42',
-          filter: null,
+          offset: '0',
           openCreateModal: false,
-        });
-
-        ctx.hash = 'create';
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          offset: '42',
           filter: null,
+        };
+
+        await checkUrlToState('/admin/groups', defaultState);
+        await checkUrlToState('/admin/groups/', defaultState);
+        await checkUrlToState('/admin/groups#create', {
+          ...defaultState,
           openCreateModal: true,
         });
-      });
-
-      test('handleGroupListFilterOffsetRoute', () => {
-        const ctx = {
-          ...createPageContext(),
-          params: {filter: 'foo', offset: '42'},
-        };
-        assertctxToParams(ctx, 'handleGroupListFilterOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
+        await checkUrlToState('/admin/groups,42', {
+          ...defaultState,
           offset: '42',
+        });
+        // #create is ignored when there is an offset
+        await checkUrlToState('/admin/groups,42#create', {
+          ...defaultState,
+          offset: '42',
+        });
+
+        await checkUrlToState('/admin/groups/q/filter:foo', {
+          ...defaultState,
           filter: 'foo',
         });
+        await checkUrlToState('/admin/groups/q/filter:foo/%2F%20%2525%252F', {
+          ...defaultState,
+          filter: 'foo// %/',
+        });
+        await checkUrlToState('/admin/groups/q/filter:foo,42', {
+          ...defaultState,
+          filter: 'foo',
+          offset: '42',
+        });
+        // #create is ignored when filtering
+        await checkUrlToState('/admin/groups/q/filter:foo,42#create', {
+          ...defaultState,
+          filter: 'foo',
+          offset: '42',
+        });
       });
 
-      test('handleGroupListFilterRoute', () => {
-        const ctx = {...createPageContext(), params: {filter: 'foo'}};
-        assertctxToParams(ctx, 'handleGroupListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          filter: 'foo',
-        });
-      });
-
-      test('handleGroupRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleGroupRoute', {
-          view: GerritView.GROUP,
-          groupId: '4321' as GroupId,
+      test('GROUP', async () => {
+        // GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+        await checkUrlToState('/admin/groups/4321', {
+          ...createGroupViewState(),
+          groupId: '4321',
         });
       });
     });
 
-    suite('repo routes', () => {
-      test('handleProjectsOldRoute', () => {
-        const ctx = {...createPageContext(), params: {}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
-      });
-
-      test('handleProjectsOldRoute test', () => {
-        const ctx = {...createPageContext(), params: {1: 'test'}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
-      });
-
-      test('handleProjectsOldRoute test,branches', () => {
-        const ctx = {...createPageContext(), params: {1: 'test,branches'}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-          redirectStub.lastCall.args[0],
+    suite('REPO*', () => {
+      test('PROJECT_OLD', async () => {
+        // PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
+        await checkRedirect('/admin/projects/', '/admin/repos/');
+        await checkRedirect('/admin/projects/test', '/admin/repos/test');
+        await checkRedirect(
+          '/admin/projects/test,branches',
           '/admin/repos/test,branches'
         );
       });
 
-      test('handleRepoRoute', () => {
-        const ctx = {...createPageContext(), path: '/admin/repos/test'};
-        router.handleRepoRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-          redirectStub.lastCall.args[0],
-          '/admin/repos/test,general'
-        );
+      test('REPO', async () => {
+        // REPO: /^\/admin\/repos\/([^,]+)$/,
+        await checkRedirect('/admin/repos/test', '/admin/repos/test,general');
       });
 
-      test('handleRepoGeneralRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoGeneralRoute', {
-          view: GerritView.REPO,
+      test('REPO_GENERAL', async () => {
+        // REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
+        await checkUrlToState('/admin/repos/4321,general', {
+          ...createRepoViewState(),
           detail: RepoDetailView.GENERAL,
           repo: '4321' as RepoName,
         });
       });
 
-      test('handleRepoCommandsRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoCommandsRoute', {
-          view: GerritView.REPO,
+      test('REPO_COMMANDS', async () => {
+        // REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+        await checkUrlToState('/admin/repos/4321,commands', {
+          ...createRepoViewState(),
           detail: RepoDetailView.COMMANDS,
           repo: '4321' as RepoName,
         });
       });
 
-      test('handleRepoAccessRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoAccessRoute', {
-          view: GerritView.REPO,
+      test('REPO_ACCESS', async () => {
+        // REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
+        await checkUrlToState('/admin/repos/4321,access', {
+          ...createRepoViewState(),
           detail: RepoDetailView.ACCESS,
           repo: '4321' as RepoName,
         });
       });
 
-      suite('branch list routes', () => {
-        test('handleBranchListOffsetRoute', () => {
-          const ctx: PageContext = {
-            ...createPageContext(),
-            params: {0: '4321'},
-          };
-          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: 0,
-            filter: null,
-          });
-
-          ctx.params[2] = '42';
-          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: null,
-          });
+      test('BRANCH_LIST', async () => {
+        await checkUrlToState('/admin/repos/4321,branches', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
         });
-
-        test('handleBranchListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleBranchListFilterOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,branches,42', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
         });
-
-        test('handleBranchListFilterRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo'},
-          };
-          assertctxToParams(ctx, 'handleBranchListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,branches/q/filter:foo,42', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
+          filter: 'foo',
         });
+        await checkUrlToState('/admin/repos/4321,branches/q/filter:foo', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          filter: 'foo',
+        });
+        await checkUrlToState(
+          '/admin/repos/asdf/%2F%20%2525%252Fqwer,branches/q/filter:foo/%2F%20%2525%252F',
+          {
+            ...createRepoBranchesViewState(),
+            repo: 'asdf// %/qwer' as RepoName,
+            filter: 'foo// %/',
+          }
+        );
       });
 
-      suite('tag list routes', () => {
-        test('handleTagListOffsetRoute', () => {
-          const ctx = {...createPageContext(), params: {0: '4321'}};
-          assertctxToParams(ctx, 'handleTagListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            offset: 0,
-            filter: null,
-          });
+      test('TAG_LIST', async () => {
+        await checkUrlToState('/admin/repos/4321,tags', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
         });
-
-        test('handleTagListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleTagListFilterOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,tags,42', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
         });
-
-        test('handleTagListFilterRoute', () => {
-          const ctx: PageContext = {
-            ...createPageContext(),
-            params: {repo: '4321'},
-          };
-          assertctxToParams(ctx, 'handleTagListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            filter: null,
-          });
-
-          ctx.params.filter = 'foo';
-          assertctxToParams(ctx, 'handleTagListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,tags/q/filter:foo,42', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
+          filter: 'foo',
         });
+        await checkUrlToState('/admin/repos/4321,tags/q/filter:foo', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          filter: 'foo',
+        });
+        await checkUrlToState(
+          '/admin/repos/asdf/%2F%20%2525%252Fqwer,tags/q/filter:foo/%2F%20%2525%252F',
+          {
+            ...createRepoTagsViewState(),
+            repo: 'asdf// %/qwer' as RepoName,
+            filter: 'foo// %/',
+          }
+        );
       });
 
-      suite('repo list routes', () => {
-        test('handleRepoListOffsetRoute', () => {
-          const ctx = createPageContext();
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: 0,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          ctx.params[1] = '42';
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: null,
-            openCreateModal: false,
-          });
-
-          ctx.hash = 'create';
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: null,
-            openCreateModal: true,
-          });
+      test('REPO_LIST', async () => {
+        await checkUrlToState('/admin/repos', {
+          ...createAdminReposViewState(),
         });
-
-        test('handleRepoListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleRepoListFilterOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/', {
+          ...createAdminReposViewState(),
         });
-
-        test('handleRepoListFilterRoute', () => {
-          const ctx = createPageContext();
-          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            filter: null,
-          });
-
-          ctx.params.filter = 'foo';
-          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos,42', {
+          ...createAdminReposViewState(),
+          offset: '42',
+        });
+        await checkUrlToState('/admin/repos#create', {
+          ...createAdminReposViewState(),
+          openCreateModal: true,
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo', {
+          ...createAdminReposViewState(),
+          filter: 'foo',
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo/%2F%20%2525%252F', {
+          ...createAdminReposViewState(),
+          filter: 'foo// %/',
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo,42', {
+          ...createAdminReposViewState(),
+          filter: 'foo',
+          offset: '42',
         });
       });
     });
 
-    suite('plugin routes', () => {
-      test('handlePluginListOffsetRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: 0,
-          filter: null,
-        });
-
-        ctx.params[1] = '42';
-        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: '42',
-          filter: null,
-        });
+    test('PLUGIN_LIST', async () => {
+      await checkUrlToState('/admin/plugins', {
+        ...createAdminPluginsViewState(),
       });
-
-      test('handlePluginListFilterOffsetRoute', () => {
-        const ctx = {
-          ...createPageContext(),
-          params: {filter: 'foo', offset: '42'},
-        };
-        assertctxToParams(ctx, 'handlePluginListFilterOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: '42',
-          filter: 'foo',
-        });
+      await checkUrlToState('/admin/plugins/', {
+        ...createAdminPluginsViewState(),
       });
-
-      test('handlePluginListFilterRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          filter: null,
-        });
-
-        ctx.params.filter = 'foo';
-        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          filter: 'foo',
-        });
+      await checkUrlToState('/admin/plugins,42', {
+        ...createAdminPluginsViewState(),
+        offset: '42',
       });
-
-      test('handlePluginListRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-        });
+      await checkUrlToState('/admin/plugins/q/filter:foo', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo%2F%20%2525%252F', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo/ %/',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo,42', {
+        ...createAdminPluginsViewState(),
+        offset: '42',
+        filter: 'foo',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo,asdf', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo,asdf',
       });
     });
 
-    suite('change/diff routes', () => {
-      test('handleChangeNumberLegacyRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '12345'}};
-        router.handleChangeNumberLegacyRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+    suite('CHANGE* / DIFF*', () => {
+      test('CHANGE_NUMBER_LEGACY', async () => {
+        // CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
+        await checkRedirect('/12345', '/c/12345');
       });
 
-      test('handleChangeLegacyRoute', async () => {
-        stubRestApi('getFromProjectLookup').returns(
-          Promise.resolve('project' as RepoName)
-        );
-        const ctx = {
-          ...createPageContext(),
-          params: {0: '1234', 1: 'comment/6789'},
-        };
-        router.handleChangeLegacyRoute(ctx);
-        await waitEventLoop();
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/project/+/1234' + '/comment/6789')
+      test('CHANGE_LEGACY', async () => {
+        // CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
+        stubRestApi('getFromProjectLookup').resolves('project' as RepoName);
+        await checkRedirect('/c/1234', '/c/project/+/1234/');
+        await checkRedirect(
+          '/c/1234/comment/6789',
+          '/c/project/+/1234/comment/6789'
         );
       });
 
-      test('handleLegacyLinenum w/ @321', () => {
-        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@321'};
-        router.handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#321')
+      test('DIFF_LEGACY_LINENUM', async () => {
+        await checkRedirect(
+          '/c/1234/3..8/foo/bar@321',
+          '/c/1234/3..8/foo/bar#321'
+        );
+        await checkRedirect(
+          '/c/1234/3..8/foo/bar@b321',
+          '/c/1234/3..8/foo/bar#b321'
         );
       });
 
-      test('handleLegacyLinenum w/ @b123', () => {
-        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@b123'};
-        router.handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#b123')
-        );
-      });
-
-      suite('handleChangeRoute', () => {
-        function makeParams(_path: string, _hash: string): PageContext {
-          return {
-            ...createPageContext(),
-            params: {
-              0: 'foo/bar', // 0 Project
-              1: '1234', // 1 Change number
-              2: '', // 2 Unused
-              3: '', // 3 Unused
-              4: '4', // 4 Base patch number
-              5: '', // 5 Unused
-              6: '7', // 6 Patch number
-            },
-          };
-        }
-
-        setup(() => {
-          stubRestApi('setInProjectLookup');
+      test('CHANGE', async () => {
+        // CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+        await checkUrlToState('/c/test-project/+/42', {
+          ...createChangeViewState(),
+          basePatchNum: undefined,
+          patchNum: undefined,
         });
-
-        test('change view', () => {
-          const ctx = makeParams('', '');
-          assertctxToParams(ctx, 'handleChangeRoute', {
-            view: GerritView.CHANGE,
-            childView: ChangeChildView.OVERVIEW,
-            repo: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
-            basePatchNum: 4 as BasePatchSetNum,
-            patchNum: 7 as RevisionPatchSetNum,
-          });
-          assert.isFalse(redirectStub.called);
+        await checkUrlToState('/c/test-project/+/42/7', {
+          ...createChangeViewState(),
+          basePatchNum: PARENT,
+          patchNum: 7,
         });
-
-        test('params', () => {
-          const ctx = makeParams('', '');
-          const queryMap = new URLSearchParams();
-          queryMap.set('tab', 'checks');
-          queryMap.set('filter', 'fff');
-          queryMap.set('select', 'sss');
-          queryMap.set('attempt', '1');
-          queryMap.set('checksRunsSelected', 'asdf,qwer');
-          queryMap.set('checksResultsFilter', 'asdf.*qwer');
-          ctx.querystring = queryMap.toString();
-          assertctxToParams(ctx, 'handleChangeRoute', {
-            view: GerritView.CHANGE,
-            childView: ChangeChildView.OVERVIEW,
-            repo: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
-            basePatchNum: 4 as BasePatchSetNum,
-            patchNum: 7 as RevisionPatchSetNum,
+        await checkUrlToState('/c/test-project/+/42/4..7', {
+          ...createChangeViewState(),
+          basePatchNum: 4,
+          patchNum: 7,
+        });
+        await checkUrlToState(
+          '/c/test-project/+/42/4..7?tab=checks&filter=fff&attempt=1&checksRunsSelected=asdf,qwer&checksResultsFilter=asdf.*qwer',
+          {
+            ...createChangeViewState(),
+            basePatchNum: 4,
+            patchNum: 7,
             attempt: 1,
             filter: 'fff',
             tab: 'checks',
             checksRunsSelected: new Set(['asdf', 'qwer']),
             checksResultsFilter: 'asdf.*qwer',
-          });
-        });
+          }
+        );
+      });
+
+      test('COMMENTS_TAB', async () => {
+        // COMMENTS_TAB: /^\/c\/(.+)\/\+\/(\d+)\/comments(?:\/)?(\w+)?\/?$/,
+        await checkUrlToState(
+          '/c/gerrit/+/264833/comments/00049681_f34fd6a9/',
+          {
+            ...createChangeViewState(),
+            repo: 'gerrit' as RepoName,
+            changeNum: 264833 as NumericChangeId,
+            commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
+            view: GerritView.CHANGE,
+            childView: ChangeChildView.OVERVIEW,
+          }
+        );
       });
 
       suite('handleDiffRoute', () => {
-        function makeParams(path: string, hash: string): PageContext {
-          return {
-            ...createPageContext(),
-            hash,
-            params: {
-              0: 'foo/bar', // 0 Project
-              1: '1234', // 1 Change number
-              2: '', // 2 Unused
-              3: '', // 3 Unused
-              4: '4', // 4 Base patch number
-              5: '', // 5 Unused
-              6: '7', // 6 Patch number
-              7: '', // 7 Unused,
-              8: path, // 8 Diff path
-            },
-          };
-        }
-
-        setup(() => {
-          stubRestApi('setInProjectLookup');
-        });
-
-        test('diff view', () => {
-          const ctx = makeParams('foo/bar/baz', 'b44');
-          assertctxToParams(ctx, 'handleDiffRoute', {
-            view: GerritView.CHANGE,
-            childView: ChangeChildView.DIFF,
-            repo: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
+        test('DIFF', async () => {
+          // DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
+          await checkUrlToState('/c/test-project/+/42/4..7/foo/bar/baz#b44', {
+            ...createDiffViewState(),
             basePatchNum: 4 as BasePatchSetNum,
             patchNum: 7 as RevisionPatchSetNum,
             diffView: {
@@ -1213,10 +888,9 @@
               leftSide: true,
             },
           });
-          assert.isFalse(redirectStub.called);
         });
 
-        test('comment route base..1', async () => {
+        test('COMMENT base..1', async () => {
           const change: ParsedChangeInfo = createParsedChange();
           const repo = change.project;
           const changeNum = change._number;
@@ -1228,19 +902,13 @@
             filepath: [{...createComment(), id, patch_set: ps, line}],
           });
 
-          const url = `/c/${repo}/+/${changeNum}/comment/${id}/`;
-          const groups = url.match(_testOnly_RoutePattern.COMMENT);
-          assert.deepEqual(groups!.slice(1), [repo, `${changeNum}`, id]);
-
-          await router.handleCommentRoute({params: groups!.slice(1)} as any);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(
-            redirectStub.lastCall.args[0],
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
             `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
           );
         });
 
-        test('comment route 1..2', async () => {
+        test('COMMENT 1..2', async () => {
           const change: ParsedChangeInfo = {
             ...createParsedChange(),
             revisions: {
@@ -1260,16 +928,11 @@
           });
           const diffStub = stubRestApi('getDiff');
 
-          const url = `/c/${repo}/+/${changeNum}/comment/${id}/`;
-          const groups = url.match(_testOnly_RoutePattern.COMMENT);
-
           // If getDiff() returns a diff with changes, then we will compare
           // the patchset of the comment (1) against latest (2).
           diffStub.onFirstCall().resolves(createDiff());
-          await router.handleCommentRoute({params: groups!.slice(1)} as any);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(
-            redirectStub.lastCall.args[0],
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
             `/c/${repo}/+/${changeNum}/${ps}..2/filepath#b${line}`
           );
 
@@ -1279,122 +942,97 @@
             ...createDiff(),
             content: [],
           });
-          await router.handleCommentRoute({params: groups!.slice(1)} as any);
-          assert.isTrue(redirectStub.calledTwice);
-          assert.equal(
-            redirectStub.lastCall.args[0],
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
             `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
           );
         });
-
-        test('comments route', () => {
-          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
-          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
-          assert.deepEqual(groups!.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertctxToParams(
-            {params: groups!.slice(1)} as any,
-            'handleCommentsRoute',
-            {
-              repo: 'gerrit' as RepoName,
-              changeNum: 264833 as NumericChangeId,
-              commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
-              view: GerritView.CHANGE,
-              childView: ChangeChildView.OVERVIEW,
-            }
-          );
-        });
       });
 
-      test('handleDiffEditRoute', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          hash: '',
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '3', // 2 Patch num
-            3: 'foo/bar/baz', // 3 File path
-          },
-        };
-        const appParams: ChangeViewState = {
+      test('DIFF_EDIT', async () => {
+        // DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
+        await checkUrlToState('/c/foo/bar/+/1234/3/foo/bar/baz,edit', {
+          ...createEditViewState(),
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
           childView: ChangeChildView.EDIT,
           patchNum: 3 as RevisionPatchSetNum,
           editView: {path: 'foo/bar/baz', lineNum: 0},
-        };
-
-        router.handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
-      });
-
-      test('handleDiffEditRoute with lineNum', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          hash: '4',
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '3', // 2 Patch num
-            3: 'foo/bar/baz', // 3 File path
-          },
-        };
-        const appParams: ChangeViewState = {
+        });
+        await checkUrlToState('/c/foo/bar/+/1234/3/foo/bar/baz,edit#4', {
+          ...createEditViewState(),
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
           childView: ChangeChildView.EDIT,
           patchNum: 3 as RevisionPatchSetNum,
           editView: {path: 'foo/bar/baz', lineNum: 4},
-        };
-
-        router.handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+        });
       });
 
-      test('handleChangeEditRoute', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '',
-            3: '3', // 3 Patch num
-          },
-        };
-        const appParams: ChangeViewState = {
+      test('CHANGE_EDIT', async () => {
+        // CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
+        await checkUrlToState('/c/foo/bar/+/1234/3,edit', {
+          ...createChangeViewState(),
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
           childView: ChangeChildView.OVERVIEW,
           patchNum: 3 as RevisionPatchSetNum,
           edit: true,
-        };
-
-        router.handleChangeEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+        });
       });
     });
 
-    test('handlePluginScreen', () => {
-      const ctx = {...createPageContext(), params: {0: 'foo', 1: 'bar'}};
-      assertctxToParams(ctx, 'handlePluginScreen', {
+    test('LOG_IN_OR_OUT pass through', async () => {
+      // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
+      await checkUrlNotMatched('/login/asdf');
+      await checkUrlNotMatched('/logout/asdf');
+    });
+
+    test('PLUGIN_SCREEN', async () => {
+      // PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
+      await checkUrlToState('/x/foo/bar', {
         view: GerritView.PLUGIN_SCREEN,
         plugin: 'foo',
         screen: 'bar',
       });
-      assert.isFalse(redirectStub.called);
+    });
+
+    test('DOCUMENTATION_SEARCH*', async () => {
+      // DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+      // DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+      await checkRedirect(
+        '/Documentation/q/asdf',
+        '/Documentation/q/filter:asdf'
+      );
+      await checkRedirect(
+        '/Documentation/q/as%3Fdf',
+        '/Documentation/q/filter:as%3Fdf'
+      );
+
+      await checkUrlToState('/Documentation/q/filter:', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: '',
+      });
+      await checkUrlToState('/Documentation/q/filter:asdf', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'asdf',
+      });
+      // Percent decoding works fine. page.js decodes twice, so the only problem
+      // is having `%25` in the URL, because the first decoding pass will yield
+      // `%`, and then the second decoding pass will throw `URI malformed`.
+      await checkUrlToState('/Documentation/q/filter:as%20%2fdf', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'as /df',
+      });
+      // We accept and process double-encoded values, but only *require* it for
+      // the percent symbol `%`.
+      await checkUrlToState('/Documentation/q/filter:as%252f%2525df', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'as/%df',
+      });
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index de92b16..23dce97 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -347,9 +347,7 @@
     );
     this.renderPrefs = {
       ...this.renderPrefs,
-      use_lit_components: this.flags.isEnabled(
-        KnownExperimentId.DIFF_RENDERING_LIT
-      ),
+      use_lit_components: true,
     };
     this.addEventListener(
       // These are named inconsistently for a reason:
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index e9da5b6..c74993f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -732,7 +732,7 @@
       changedProperties.has('focusLineNum') ||
       changedProperties.has('leftSide')
     ) {
-      this.initLineOfInterestAndCursor();
+      this.initCursor();
     }
     if (
       changedProperties.has('change') ||
@@ -774,6 +774,7 @@
         .change=${this.change}
         .patchRange=${this.patchRange}
         .file=${file}
+        .lineOfInterest=${this.getLineOfInterest()}
         .path=${this.path}
         .projectName=${this.change?.project}
         @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
@@ -1350,13 +1351,6 @@
     return {path: fileList[idx]};
   }
 
-  // Private but used in tests.
-  initLineOfInterestAndCursor() {
-    if (!this.diffHost) return;
-    this.diffHost.lineOfInterest = this.getLineOfInterest();
-    this.initCursor();
-  }
-
   private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
     if (!this.change) return;
     if (!this.patchNum) return;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 9bbe4b3..889e9dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -1315,7 +1315,7 @@
     test('hash is determined from viewState', async () => {
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload');
-      const initLineStub = sinon.stub(element, 'initLineOfInterestAndCursor');
+      const initLineStub = sinon.stub(element, 'initCursor');
 
       element.focusLineNum = 123;
 
@@ -1819,7 +1819,7 @@
 
     test('File change should trigger setUrl once', async () => {
       element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      sinon.stub(element, 'initLineOfInterestAndCursor');
+      sinon.stub(element, 'initCursor');
 
       // Load file1
       viewModel.setState({
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index 0092193..ea5e9f3 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -6,10 +6,11 @@
 import '../../../test/common-test-setup';
 import './gr-documentation-search';
 import {GrDocumentationSearch} from './gr-documentation-search';
-import {page} from '../../../utils/page-wrapper-utils';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {DocResult} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function documentationGenerator(counter: number) {
   return {
@@ -31,7 +32,7 @@
   let documentationSearches: DocResult[];
 
   setup(async () => {
-    sinon.stub(page, 'show');
+    sinon.stub(testResolver(navigationToken), 'setUrl');
     element = await fixture(
       html`<gr-documentation-search></gr-documentation-search>`
     );
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 0261992..68e7309 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -28,6 +28,7 @@
   justRegistered: boolean;
 }
 
+// TODO: Get rid of this type. <gr-app-element> needs to be refactored for that.
 export type AppElementParams =
   | DashboardViewState
   | GroupViewState
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index e7c0536..71f8391 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -88,7 +88,7 @@
       /* HTML */ `
         <div class="container">
           <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
-          <a class="ownerLink" href="/q/owner:user-31%2540" tabindex="-1">
+          <a class="ownerLink" href="/q/owner:user-31@" tabindex="-1">
             <span class="hovercardTargetWrapper">
               <gr-avatar hidden="" imagesize="32"> </gr-avatar>
               <span
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 66beaf1..c1cc863 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -275,9 +275,7 @@
         this.save();
       });
     }
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      this.messagePlaceholder = 'Mention others with @';
-    }
+    this.messagePlaceholder = 'Mention others with @';
     subscribe(
       this,
       () => this.getUserModel().account$,
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 627ea27..45eca40 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -18,8 +18,6 @@
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
 import '../gr-account-chip/gr-account-chip';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {getAppContext} from '../../../services/app-context';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -36,8 +34,6 @@
   @state()
   private repoCommentLinks: CommentLinks = {};
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getConfigModel = resolve(this, configModelToken);
 
   // Private const but used in tests.
@@ -214,9 +210,7 @@
 
   override updated() {
     // Look for @mentions and replace them with an account-label chip.
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      this.convertEmailsToAccountChips();
-    }
+    this.convertEmailsToAccountChips();
   }
 
   private convertEmailsToAccountChips() {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 3881c62..3187ada 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -15,14 +15,9 @@
 import './gr-formatted-text';
 import {GrFormattedText} from './gr-formatted-text';
 import {createConfig} from '../../../test/test-data-generators';
-import {
-  queryAndAssert,
-  stubFlags,
-  waitUntilObserved,
-} from '../../../test/test-utils';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {testResolver} from '../../../test/common-test-setup';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 
 suite('gr-formatted-text tests', () => {
@@ -412,38 +407,7 @@
       );
     });
 
-    test('does not handle @mentions if not enabled', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(false);
-      element.content = '@someone@google.com';
-      await element.updateComplete;
-
-      assert.shadowDom.equal(
-        element,
-        /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>
-                @
-                <a
-                  href="mailto:someone@google.com"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  someone@google.com
-                </a>
-              </p>
-            </div>
-          </marked-element>
-        `
-      );
-    });
-
-    test('handles @mentions if enabled', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
+    test('handles @mentions', async () => {
       element.content = '@someone@google.com';
       await element.updateComplete;
 
@@ -470,9 +434,6 @@
     });
 
     test('does not handle @mentions that is part of a code block', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
       element.content = '`@`someone@google.com';
       await element.updateComplete;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
index b217562..a1ac781 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -79,7 +79,7 @@
         </div>
         <div class="links">
           <gr-icon icon="link" class="linkIcon"></gr-icon>
-          <a href="/q/owner:kermit%2540gmail.com">Changes</a>
+          <a href="/q/owner:kermit@gmail.com">Changes</a>
           ·
           <a href="/dashboard/31415926535">Dashboard</a>
         </div>
@@ -114,7 +114,7 @@
         </div>
         <div class="links">
           <gr-icon class="linkIcon" icon="link"> </gr-icon>
-          <a href="/q/owner:kermit%2540gmail.com"> Changes </a>
+          <a href="/q/owner:kermit@gmail.com"> Changes </a>
           ·
           <a href="/dashboard/31415926535"> Dashboard </a>
         </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index 449c041..7e90c8d 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -7,13 +7,14 @@
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
 import {fireEvent} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -48,6 +49,8 @@
 
   private reloadTask?: DelayedTask;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   override disconnectedCallback() {
     this.reloadTask?.cancel();
     super.disconnectedCallback();
@@ -121,30 +124,18 @@
       </div>
       <slot></slot>
       <nav>
-        Page ${this.computePage(this.offset, this.itemsPerPage)}
+        Page ${this.computePage()}
         <a
           id="prevArrow"
-          href=${this.computeNavLink(
-            this.offset,
-            -1,
-            this.itemsPerPage,
-            this.filter,
-            this.path
-          )}
+          href=${this.computeNavLink(-1)}
           ?hidden=${this.loading || this.offset === 0}
         >
           <gr-icon icon="chevron_left"></gr-icon>
         </a>
         <a
           id="nextArrow"
-          href=${this.computeNavLink(
-            this.offset,
-            1,
-            this.itemsPerPage,
-            this.filter,
-            this.path
-          )}
-          ?hidden=${this.hideNextArrow(this.loading, this.items)}
+          href=${this.computeNavLink(1)}
+          ?hidden=${this.hideNextArrow()}
         >
           <gr-icon icon="chevron_right"></gr-icon>
         </a>
@@ -177,12 +168,12 @@
       () => {
         if (!this.isConnected || !this.path) return;
         if (filter) {
-          // TODO: Use navigation service instead of `page.show()` directly.
-          page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
+          this.getNavigation().setUrl(
+            `${this.path}/q/filter:${encodeURL(filter)}`
+          );
           return;
         }
-        // TODO: Use navigation service instead of `page.show()` directly.
-        page.show(this.path);
+        this.getNavigation().setUrl(this.path);
       },
       REQUEST_DEBOUNCE_INTERVAL_MS
     );
@@ -193,19 +184,13 @@
   }
 
   // private but used in test
-  computeNavLink(
-    offset: number,
-    direction: number,
-    itemsPerPage: number,
-    filter: string | undefined,
-    path = ''
-  ) {
+  computeNavLink(direction: number) {
     // Offset could be a string when passed from the router.
-    offset = +(offset || 0);
-    const newOffset = Math.max(0, offset + itemsPerPage * direction);
-    let href = getBaseUrl() + path;
-    if (filter) {
-      href += '/q/filter:' + encodeURL(filter, false);
+    const offset = +(this.offset || 0);
+    const newOffset = Math.max(0, offset + this.itemsPerPage * direction);
+    let href = getBaseUrl() + (this.path ?? '');
+    if (this.filter) {
+      href += '/q/filter:' + encodeURL(this.filter);
     }
     if (newOffset > 0) {
       href += `,${newOffset}`;
@@ -214,11 +199,9 @@
   }
 
   // private but used in test
-  hideNextArrow(loading?: boolean, items?: unknown[]) {
-    if (loading || !items || !items.length) {
-      return true;
-    }
-    const lastPage = items.length < this.itemsPerPage + 1;
+  hideNextArrow() {
+    if (this.loading || !this.items?.length) return true;
+    const lastPage = this.items.length < this.itemsPerPage + 1;
     return lastPage;
   }
 
@@ -226,8 +209,8 @@
   // to either support a decimal or make it go to the nearest
   // whole number (e.g 3).
   // private but used in test
-  computePage(offset: number, itemsPerPage: number) {
-    return offset / itemsPerPage + 1;
+  computePage() {
+    return this.offset / this.itemsPerPage + 1;
   }
 
   private handleFilterBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
index bbbef72..cce2840 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -6,10 +6,11 @@
 import '../../../test/common-test-setup';
 import './gr-list-view';
 import {GrListView} from './gr-list-view';
-import {page} from '../../../utils/page-wrapper-utils';
 import {queryAndAssert, stubBaseUrl} from '../../../test/test-utils';
 import {GrButton} from '../gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-list-view tests', () => {
   let element: GrListView;
@@ -57,37 +58,26 @@
   });
 
   test('computeNavLink', () => {
-    const offset = 25;
-    const projectsPerPage = 25;
-    let filter = 'test';
-    const path = '/admin/projects';
+    element.offset = 25;
+    element.itemsPerPage = 25;
+    element.filter = 'test';
+    element.path = '/admin/projects';
 
     stubBaseUrl('');
 
-    assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:test,50'
-    );
+    assert.equal(element.computeNavLink(1), '/admin/projects/q/filter:test,50');
 
-    assert.equal(
-      element.computeNavLink(offset, -1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:test'
-    );
+    assert.equal(element.computeNavLink(-1), '/admin/projects/q/filter:test');
 
-    assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, undefined, path),
-      '/admin/projects,50'
-    );
+    element.filter = undefined;
+    assert.equal(element.computeNavLink(1), '/admin/projects,50');
 
-    assert.equal(
-      element.computeNavLink(offset, -1, projectsPerPage, undefined, path),
-      '/admin/projects'
-    );
+    assert.equal(element.computeNavLink(-1), '/admin/projects');
 
-    filter = 'plugins/';
+    element.filter = 'plugins/';
     assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:plugins%252F,50'
+      element.computeNavLink(1),
+      '/admin/projects/q/filter:plugins/,50'
     );
   });
 
@@ -95,7 +85,9 @@
     let resolve: (url: string) => void;
     const promise = new Promise(r => (resolve = r));
     element.path = '/admin/projects';
-    sinon.stub(page, 'show').callsFake(r => resolve(r));
+    sinon
+      .stub(testResolver(navigationToken), 'setUrl')
+      .callsFake(r => resolve(r));
 
     element.filter = 'test';
     await element.updateComplete;
@@ -113,19 +105,19 @@
 
   test('next button', async () => {
     element.itemsPerPage = 25;
-    let projects = new Array(26);
+    element.items = new Array(26);
+    element.loading = false;
     await element.updateComplete;
 
-    let loading;
-    assert.isFalse(element.hideNextArrow(loading, projects));
-    loading = true;
-    assert.isTrue(element.hideNextArrow(loading, projects));
-    loading = false;
-    assert.isFalse(element.hideNextArrow(loading, projects));
-    projects = [];
-    assert.isTrue(element.hideNextArrow(loading, projects));
-    projects = new Array(4);
-    assert.isTrue(element.hideNextArrow(loading, projects));
+    assert.isFalse(element.hideNextArrow());
+    element.loading = true;
+    assert.isTrue(element.hideNextArrow());
+    element.loading = false;
+    assert.isFalse(element.hideNextArrow());
+    element.items = [];
+    assert.isTrue(element.hideNextArrow());
+    element.items = new Array(4);
+    assert.isTrue(element.hideNextArrow());
   });
 
   test('prev button', async () => {
@@ -186,20 +178,40 @@
   test('next/prev links change when path changes', async () => {
     const BRANCHES_PATH = '/path/to/branches';
     const TAGS_PATH = '/path/to/tags';
-    const computeNavLinkStub = sinon.stub(element, 'computeNavLink');
     element.offset = 0;
     element.itemsPerPage = 25;
     element.filter = '';
     element.path = BRANCHES_PATH;
     await element.updateComplete;
-    assert.equal(computeNavLinkStub.lastCall.args[4], BRANCHES_PATH);
+
+    assert.dom.equal(
+      queryAndAssert(element, 'nav a'),
+      /* HTML */ `
+        <a hidden="" href="${BRANCHES_PATH}" id="prevArrow">
+          <gr-icon icon="chevron_left"> </gr-icon>
+        </a>
+      `
+    );
+
     element.path = TAGS_PATH;
     await element.updateComplete;
-    assert.equal(computeNavLinkStub.lastCall.args[4], TAGS_PATH);
+
+    assert.dom.equal(
+      queryAndAssert(element, 'nav a'),
+      /* HTML */ `
+        <a hidden="" href="${TAGS_PATH}" id="prevArrow">
+          <gr-icon icon="chevron_left"> </gr-icon>
+        </a>
+      `
+    );
   });
 
   test('computePage', () => {
-    assert.equal(element.computePage(0, 25), 1);
-    assert.equal(element.computePage(50, 25), 3);
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    assert.equal(element.computePage(), 1);
+    element.offset = 50;
+    element.itemsPerPage = 25;
+    assert.equal(element.computePage(), 3);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index a1a1e84..e1e4ca5 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -17,12 +17,11 @@
 import {Key} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {LitElement, css, html, nothing} from 'lit';
+import {LitElement, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {PropertyValues} from 'lit';
 import {classMap} from 'lit/directives/class-map.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {NumericChangeId, ServerInfo} from '../../../api/rest-api';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
@@ -115,8 +114,6 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -265,8 +262,6 @@
   }
 
   private renderMentionsDropdown() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return nothing;
     return html` <gr-autocomplete-dropdown
       id="mentionsSuggestions"
       .suggestions=${this.suggestions}
@@ -524,8 +519,6 @@
   }
 
   private isMentionsDropdownActive() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return false;
     return (
       this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === '@'
     );
@@ -540,10 +533,8 @@
   private computeSpecialCharIndex() {
     const charAtCursor = this.text[this.textarea!.selectionStart - 1];
 
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      if (charAtCursor === '@' && this.specialCharIndex === -1) {
-        this.specialCharIndex = this.getSpecialCharIndex(this.text);
-      }
+    if (charAtCursor === '@' && this.specialCharIndex === -1) {
+      this.specialCharIndex = this.getSpecialCharIndex(this.text);
     }
     if (charAtCursor === ':' && this.specialCharIndex === -1) {
       this.specialCharIndex = this.getSpecialCharIndex(this.text);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index f8ae38c..0400e85 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -7,12 +7,7 @@
 import './gr-textarea';
 import {GrTextarea} from './gr-textarea';
 import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {
-  pressKey,
-  stubFlags,
-  stubRestApi,
-  waitUntil,
-} from '../../../test/test-utils';
+import {pressKey, stubRestApi, waitUntil} from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
 import {createAccountWithEmail} from '../../../test/test-data-generators';
 import {Key} from '../../../utils/dom-util';
@@ -31,14 +26,16 @@
       element,
       /* HTML */ `<div id="hiddenText"></div>
         <span id="caratSpan"> </span>
+        <gr-autocomplete-dropdown id="emojiSuggestions" is-hidden="">
+        </gr-autocomplete-dropdown>
         <gr-autocomplete-dropdown
-          id="emojiSuggestions"
+          id="mentionsSuggestions"
           is-hidden=""
-          style="position: fixed; top: 150px; left: 392.5px; box-sizing: border-box; max-height: 300px; max-width: 785px;"
+          role="listbox"
         >
         </gr-autocomplete-dropdown>
         <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
-        </iron-autogrow-textarea> `,
+        </iron-autogrow-textarea>`,
       {
         // gr-autocomplete-dropdown sizing seems to vary between local & CI
         ignoreAttributes: [
@@ -49,47 +46,6 @@
   });
 
   suite('mention users', () => {
-    setup(async () => {
-      stubFlags('isEnabled').returns(true);
-      element.requestUpdate();
-      await element.updateComplete;
-    });
-
-    test('renders', () => {
-      assert.shadowDom.equal(
-        element,
-        /* HTML */ `
-          <div id="hiddenText"></div>
-          <span id="caratSpan"> </span>
-          <gr-autocomplete-dropdown
-            id="emojiSuggestions"
-            is-hidden=""
-            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
-          >
-          </gr-autocomplete-dropdown>
-          <gr-autocomplete-dropdown
-            id="mentionsSuggestions"
-            is-hidden=""
-            role="listbox"
-            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
-          >
-          </gr-autocomplete-dropdown>
-          <iron-autogrow-textarea
-            focused=""
-            aria-disabled="false"
-            id="textarea"
-          >
-          </iron-autogrow-textarea>
-        `,
-        {
-          // gr-autocomplete-dropdown sizing seems to vary between local & CI
-          ignoreAttributes: [
-            {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
-          ],
-        }
-      );
-    });
-
     test('mentions selector is open when @ is typed & the textarea has focus', async () => {
       // Needed for Safari tests. selectionStart is not updated when text is
       // updated.
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index 28e83ae..b952a3d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -76,6 +76,9 @@
 
     const pairs = this.getLinePairs();
     const responsiveMode = getResponsiveMode(this.diffPrefs, this.renderPrefs);
+    const hideFileCommentButton =
+      this.diffPrefs?.show_file_comment_button === false ||
+      this.renderPrefs?.show_file_comment_button === false;
     const body = html`
       <tbody class=${diffClasses(...extras)}>
         ${this.renderContextControls()} ${this.renderMoveControls()}
@@ -92,6 +95,7 @@
               .tabSize=${this.diffPrefs?.tab_size ?? 2}
               .unifiedDiff=${this.isUnifiedDiff()}
               .responsiveMode=${responsiveMode}
+              .hideFileCommentButton=${hideFileCommentButton}
             >
             </gr-diff-row>
           `;
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 6a5933c..ba43eb4 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -14,12 +14,13 @@
   Replacement,
   RunStatus,
 } from '../../api/checks';
-import {PatchSetNumber} from '../../api/rest-api';
+import {PatchSetNumber, RevisionPatchSetNum} from '../../api/rest-api';
+import {CommentSide} from '../../constants/constants';
 import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {isDefined} from '../../types/types';
-import {PROVIDED_FIX_ID} from '../../utils/comment-util';
-import {assert, assertNever} from '../../utils/common-util';
+import {PROVIDED_FIX_ID, UnsavedInfo} from '../../utils/comment-util';
+import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
 import {fire} from '../../utils/event-util';
 import {CheckResult, CheckRun, RunResult} from './checks-model';
 
@@ -86,6 +87,27 @@
   }
 }
 
+function pleaseFixMessage(result: RunResult) {
+  return `Please fix this ${result.category} reported by ${result.checkName}: ${result.summary}
+
+${result.message}`;
+}
+
+export function createPleaseFixComment(result: RunResult): UnsavedInfo {
+  const pointer = result.codePointers?.[0];
+  assertIsDefined(pointer, 'codePointer');
+  return {
+    __unsaved: true,
+    path: pointer.path,
+    patch_set: result.patchset as RevisionPatchSetNum,
+    side: CommentSide.REVISION,
+    line: pointer.range.end_line ?? pointer.range.start_line,
+    range: pointer.range,
+    message: pleaseFixMessage(result),
+    unresolved: true,
+  };
+}
+
 export function createFixAction(
   target: EventTarget,
   result?: RunResult
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 3380637..de9ec5d 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -7,7 +7,18 @@
 import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
-import {ViewState} from './base';
+import {Route, ViewState} from './base';
+
+export const PLUGIN_LIST_ROUTE: Route<AdminViewState> = {
+  urlPattern: /^\/admin\/plugins(\/)?$/,
+  createState: () => {
+    const state: AdminViewState = {
+      view: GerritView.ADMIN,
+      adminView: AdminChildView.PLUGINS,
+    };
+    return state;
+  },
+};
 
 export enum AdminChildView {
   REPOS = 'gr-repo-list',
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
new file mode 100644
index 0000000..c9b7801
--- /dev/null
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {GerritView} from '../../services/router/router-model';
+import '../../test/common-test-setup';
+import {AdminChildView, PLUGIN_LIST_ROUTE} from './admin';
+
+suite('admin view model', () => {
+  suite('routes', () => {
+    test('PLUGIN_LIST', () => {
+      const {urlPattern: pattern, createState} = PLUGIN_LIST_ROUTE;
+
+      assert.isTrue(pattern.test('/admin/plugins'));
+      assert.isTrue(pattern.test('/admin/plugins/'));
+      assert.isFalse(pattern.test('admin/plugins'));
+      assert.isFalse(pattern.test('//admin/plugins'));
+      assert.isFalse(pattern.test('//admin/plugins?'));
+      assert.isFalse(pattern.test('/admin/plugins//'));
+
+      assert.deepEqual(createState({}), {
+        view: GerritView.ADMIN,
+        adminView: AdminChildView.PLUGINS,
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/views/base.ts b/polygerrit-ui/app/models/views/base.ts
index 065495d..72bec33 100644
--- a/polygerrit-ui/app/models/views/base.ts
+++ b/polygerrit-ui/app/models/views/base.ts
@@ -8,3 +8,24 @@
 export interface ViewState {
   view: GerritView;
 }
+
+/**
+ * While we are using page.js this interface will normally be implemented by
+ * PageContext, but it helps testing and independence to have our own type
+ * here.
+ */
+export interface UrlInfo {
+  querystring?: string;
+  hash?: string;
+  /** What the regular expression matching returns. */
+  params?: {[paramIndex: string]: string};
+}
+
+/**
+ * Based on `urlPattern` knows whether a URL matches and if so, then
+ * `createState()` can produce a `ViewState` from the matched URL.
+ */
+export interface Route<T extends ViewState> {
+  urlPattern: RegExp;
+  createState: (info: UrlInfo) => T;
+}
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index a206037..153777f 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -196,7 +196,7 @@
     childView: ChangeChildView.DIFF,
   });
 
-  const path = `/${encodeURL(state.diffView?.path ?? '', true)}`;
+  const path = `/${encodeURL(state.diffView?.path ?? '')}`;
 
   let suffix = '';
   // TODO: Move creating of comment URLs to a separate function. We are
@@ -226,7 +226,7 @@
     patchNum: obj.patchNum ?? EDIT,
   });
 
-  const path = `/${encodeURL(state.editView?.path ?? '', true)}`;
+  const path = `/${encodeURL(state.editView?.path ?? '')}`;
   const line = state.editView?.lineNum;
   const suffix = line ? `#${line}` : '';
 
@@ -242,7 +242,7 @@
   if (range.length) range = '/' + range;
 
   let repo = '';
-  if (state.repo) repo = `${encodeURL(state.repo, true)}/+/`;
+  if (state.repo) repo = `${encodeURL(state.repo)}/+/`;
 
   return `${getBaseUrl()}/c/${repo}${state.changeNum}${range}`;
 }
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index 837e362..1e71bbd 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -74,7 +74,7 @@
       ...createChangeViewState(),
       repo: 'x+/y+/z+/w' as RepoName,
     };
-    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/42');
+    assert.equal(createChangeUrl(state), '/c/x%2B/y%2B/z%2B/w/+/42');
   });
 
   test('createDiffUrl', () => {
@@ -85,7 +85,7 @@
     };
     assert.equal(
       createDiffUrl(params),
-      '/c/test-project/+/42/12/x%252By/path.cpp'
+      '/c/test-project/+/42/12/x%2By/path.cpp'
     );
 
     window.CANONICAL_PATH = '/base';
@@ -93,10 +93,10 @@
     window.CANONICAL_PATH = undefined;
 
     params.repo = 'test' as RepoName;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%2By/path.cpp');
 
     params.basePatchNum = 6 as BasePatchSetNum;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%2By/path.cpp');
 
     params.diffView = {
       path: 'foo bar/my+file.txt%',
@@ -105,7 +105,7 @@
     delete params.basePatchNum;
     assert.equal(
       createDiffUrl(params),
-      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+      '/c/test/+/42/2/foo+bar/my%2Bfile.txt%2525'
     );
 
     params.diffView = {
@@ -129,7 +129,7 @@
       repo: 'x+/y' as RepoName,
       diffView: {path: 'x+y/path.cpp'},
     };
-    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/x%2B/y/+/42/12/x%2By/path.cpp');
   });
 
   test('createEditUrl', () => {
@@ -140,7 +140,7 @@
     };
     assert.equal(
       createEditUrl(params),
-      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
+      '/c/test-project/+/42/12/x%2By/path.cpp,edit#31'
     );
 
     window.CANONICAL_PATH = '/base';
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index d9ff2d2..74523db 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -33,7 +33,7 @@
     const query = repoName
       ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
       : section.query;
-    return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+    return encodeURL(section.name) + '=' + encodeURL(query);
   });
 }
 
@@ -43,13 +43,13 @@
     // Custom dashboard.
     const queryParams = sectionsToEncodedParams(state.sections, repoName);
     if (state.title) {
-      queryParams.push('title=' + encodeURIComponent(state.title));
+      queryParams.push('title=' + encodeURL(state.title));
     }
     const user = state.user ? state.user : '';
     return `${getBaseUrl()}/dashboard/${user}?${queryParams.join('&')}`;
   } else if (repoName) {
     // Project dashboard.
-    const encodedRepo = encodeURL(repoName, true);
+    const encodedRepo = encodeURL(repoName);
     return `${getBaseUrl()}/p/${encodedRepo}/+/dashboard/${state.dashboard}`;
   } else {
     // User dashboard.
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index 86bb5c0..a7620dd 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -34,7 +34,7 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/?section%201=query%201&section%202=query%202'
+        '/dashboard/?section+1=query+1&section+2=query+2'
       );
     });
 
@@ -48,8 +48,8 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/?section%201=query%201%20repo-name&' +
-          'section%202=query%202%20repo-name'
+        '/dashboard/?section+1=query+1+repo-name&' +
+          'section+2=query+2+repo-name'
       );
     });
 
@@ -61,7 +61,7 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/user?name=query&title=custom%20dashboard'
+        '/dashboard/user?name=query&title=custom+dashboard'
       );
     });
 
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
index b564d64..6789695 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -10,7 +10,7 @@
 
 export interface DocumentationViewState extends ViewState {
   view: GerritView.DOCUMENTATION_SEARCH;
-  filter?: string | null;
+  filter: string;
 }
 
 export const documentationViewModelToken = define<DocumentationViewModel>(
diff --git a/polygerrit-ui/app/models/views/group.ts b/polygerrit-ui/app/models/views/group.ts
index 277bcff..2ab3735 100644
--- a/polygerrit-ui/app/models/views/group.ts
+++ b/polygerrit-ui/app/models/views/group.ts
@@ -22,7 +22,7 @@
 }
 
 export function createGroupUrl(state: Omit<GroupViewState, 'view'>) {
-  let url = `/admin/groups/${encodeURL(`${state.groupId}`, true)}`;
+  let url = `/admin/groups/${encodeURL(`${state.groupId}`)}`;
   if (state.detail === GroupDetailView.MEMBERS) {
     url += ',members';
   } else if (state.detail === GroupDetailView.LOG) {
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index ec65ca1..66bf5bf 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -36,7 +36,7 @@
 }
 
 export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
-  let url = `/admin/repos/${encodeURL(`${state.repo}`, true)}`;
+  let url = `/admin/repos/${encodeURL(`${state.repo}`)}`;
   if (state.detail === RepoDetailView.GENERAL) {
     url += ',general';
   } else if (state.detail === RepoDetailView.ACCESS) {
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
index c5d394d..48775ce 100644
--- a/polygerrit-ui/app/models/views/search.ts
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -86,42 +86,39 @@
   }
 
   if (params.query) {
-    return `${getBaseUrl()}/q/${encodeURL(params.query, true)}${offsetExpr}`;
+    return `${getBaseUrl()}/q/${encodeURL(params.query)}${offsetExpr}`;
   }
 
   const operators: string[] = [];
   if (params.owner) {
-    operators.push('owner:' + encodeURL(params.owner, false));
+    operators.push('owner:' + encodeURL(params.owner));
   }
   if (params.repo) {
-    operators.push('project:' + encodeURL(params.repo, false));
+    operators.push('project:' + encodeURL(params.repo));
   }
   if (params.branch) {
-    operators.push('branch:' + encodeURL(params.branch, false));
+    operators.push('branch:' + encodeURL(params.branch));
   }
   if (params.topic) {
     operators.push(
-      'topic:' +
-        escapeAndWrapSearchOperatorValue(encodeURL(params.topic, false))
+      'topic:' + escapeAndWrapSearchOperatorValue(encodeURL(params.topic))
     );
   }
   if (params.hashtag) {
     operators.push(
       'hashtag:' +
         escapeAndWrapSearchOperatorValue(
-          encodeURL(params.hashtag.toLowerCase(), false)
+          encodeURL(params.hashtag.toLowerCase())
         )
     );
   }
   if (params.statuses) {
     if (params.statuses.length === 1) {
-      operators.push('status:' + encodeURL(params.statuses[0], false));
+      operators.push('status:' + encodeURL(params.statuses[0]));
     } else if (params.statuses.length > 1) {
       operators.push(
         '(' +
-          params.statuses
-            .map(s => `status:${encodeURL(s, false)}`)
-            .join(' OR ') +
+          params.statuses.map(s => `status:${encodeURL(s)}`).join(' OR ') +
           ')'
       );
     }
diff --git a/polygerrit-ui/app/models/views/search_test.ts b/polygerrit-ui/app/models/views/search_test.ts
index 6809225..e2e02f5 100644
--- a/polygerrit-ui/app/models/views/search_test.ts
+++ b/polygerrit-ui/app/models/views/search_test.ts
@@ -56,10 +56,10 @@
 
     // The presence of the query param overrides other options.
     options.query = 'foo$bar';
-    assert.equal(createSearchUrl(options), '/q/foo%2524bar');
+    assert.equal(createSearchUrl(options), '/q/foo%24bar');
 
     options.offset = 100;
-    assert.equal(createSearchUrl(options), '/q/foo%2524bar,100');
+    assert.equal(createSearchUrl(options), '/q/foo%24bar,100');
 
     options = {statuses: ['a', 'b', 'c']};
     assert.equal(
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 2a5dff2..29e9259 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -17,10 +17,7 @@
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
-  DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
   SUGGEST_EDIT = 'UiFeature__suggest_edit',
-  MENTION_USERS = 'UiFeature__mention_users',
-  RENDER_MARKDOWN = 'UiFeature__render_markdown',
   REBASE_CHAIN = 'UiFeature__rebase_chain',
 }
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index c3c1cb6..5e2cc10 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -21,12 +21,17 @@
   SETTINGS = 'settings',
 }
 
+// TODO: Consider renaming this to AppElementState or something similar.
+// Or maybe RootViewState. This class does *not* model the state of the router.
 export interface RouterState {
   // Note that this router model view must be updated before view model state.
   view?: GerritView;
 }
 
 export const routerModelToken = define<RouterModel>('router-model');
+
+// TODO: Consider renaming this to AppElementViewModel or something similar.
+// Or maybe RootViewModel. This class is *not* a view model of the router.
 export class RouterModel extends Model<RouterState> {
   readonly routerView$: Observable<GerritView | undefined> = select(
     this.state$,
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index b6e8f60..963b2a2 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -16,13 +16,16 @@
     border-top: 1px solid transparent;
     display: block;
     padding: 0 var(--spacing-xl);
-  }
-  .navStyles li a {
-    display: block;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
   }
+  .navStyles li a {
+    display: block;
+    /* overflow and text-overflow are not inherited, must repeat them */
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
   .navStyles .subsectionItem {
     padding-left: var(--spacing-xxl);
   }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index f0a4cbe..b480bfe 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -116,6 +116,10 @@
 import {SearchViewState} from '../models/views/search';
 import {ChangeChildView, ChangeViewState} from '../models/views/change';
 import {NormalizedFileInfo} from '../models/change/files-model';
+import {GroupViewState} from '../models/views/group';
+import {RepoDetailView, RepoViewState} from '../models/views/repo';
+import {AdminChildView, AdminViewState} from '../models/views/admin';
+import {DashboardViewState} from '../models/views/dashboard';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -741,6 +745,73 @@
   };
 }
 
+export function createSearchViewState(): SearchViewState {
+  return {
+    view: GerritView.SEARCH,
+    query: '',
+    offset: undefined,
+    loading: false,
+    changes: [],
+  };
+}
+
+export function createDashboardViewState(): DashboardViewState {
+  return {
+    view: GerritView.DASHBOARD,
+    user: 'self',
+  };
+}
+
+export function createAdminReposViewState(): AdminViewState {
+  return {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.REPOS,
+    offset: '0',
+    filter: null,
+    openCreateModal: false,
+  };
+}
+
+export function createAdminPluginsViewState(): AdminViewState {
+  return {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.PLUGINS,
+    offset: '0',
+    filter: null,
+  };
+}
+
+export function createGroupViewState(): GroupViewState {
+  return {
+    view: GerritView.GROUP,
+    groupId: 'test-group-id' as GroupId,
+  };
+}
+
+export function createRepoViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+  };
+}
+
+export function createRepoBranchesViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+    detail: RepoDetailView.BRANCHES,
+    offset: '0',
+    filter: null,
+  };
+}
+
+export function createRepoTagsViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+    detail: RepoDetailView.TAGS,
+    offset: '0',
+    filter: null,
+  };
+}
+
 export function createRequirement(): Requirement {
   return {
     status: RequirementStatus.OK,
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 77834bd..4404e59 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -3,7 +3,12 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
+import {
+  AccountInfo,
+  ChangeInfo,
+  DetailedLabelInfo,
+  ServerInfo,
+} from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {
   getAccountTemplate,
@@ -13,6 +18,7 @@
 } from './account-util';
 import {CommentThread, isMentionedThread, isUnresolved} from './comment-util';
 import {hasOwnProperty} from './common-util';
+import {getCodeReviewLabel} from './label-util';
 
 export function canHaveAttention(account?: AccountInfo): boolean {
   return !!account?._account_id && !isServiceUser(account);
@@ -101,9 +107,10 @@
 /**
  *  Sort order:
  * 1. The user themselves
- * 2. Human users in the attention set.
- * 3. Other human users.
- * 4. Service users.
+ * 2. Users in the attention set first.
+ * 3. Human users first.
+ * 4. Users that have voted first in this order of vote values:
+ *    -2, -1, +2, +1, 0 or no vote.
  */
 export function sortReviewers(
   r1: AccountInfo,
@@ -117,7 +124,22 @@
   }
   const a1 = hasAttention(r1, change) ? 1 : 0;
   const a2 = hasAttention(r2, change) ? 1 : 0;
-  const s1 = isServiceUser(r1) ? -2 : 0;
-  const s2 = isServiceUser(r2) ? -2 : 0;
-  return a2 - a1 + s2 - s1;
+  if (a2 - a1 !== 0) return a2 - a1;
+
+  const s1 = isServiceUser(r1) ? -1 : 0;
+  const s2 = isServiceUser(r2) ? -1 : 0;
+  if (s2 - s1 !== 0) return s2 - s1;
+
+  const crLabel = getCodeReviewLabel(change?.labels ?? {}) as DetailedLabelInfo;
+  let v1 =
+    crLabel?.all?.find(vote => vote._account_id === r1._account_id)?.value ?? 0;
+  let v2 =
+    crLabel?.all?.find(vote => vote._account_id === r2._account_id)?.value ?? 0;
+  // We want negative votes getting a higher score than positive votes, so
+  // we choose 10 as a random number that is higher than all positive votes that
+  // are in use, and then add the absolute value of the vote to that.
+  // So -2 becomes 12.
+  if (v1 < 0) v1 = 10 - v1;
+  if (v2 < 0) v2 = 10 - v2;
+  return v2 - v1;
 }
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.ts b/polygerrit-ui/app/utils/attention-set-util_test.ts
index 8092a6e..5bd1924 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.ts
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -6,9 +6,11 @@
 import '../test/common-test-setup';
 import {
   createAccountDetailWithIdNameAndEmail,
+  createAccountWithId,
   createChange,
   createComment,
   createCommentThread,
+  createParsedChange,
   createServerInfo,
 } from '../test/test-data-generators';
 import {
@@ -22,9 +24,10 @@
   getMentionedReason,
   getReason,
   hasAttention,
+  sortReviewers,
 } from './attention-set-util';
 import {DefaultDisplayNameConfig} from '../api/rest-api';
-import {AccountsVisibility} from '../constants/constants';
+import {AccountsVisibility, AccountTag} from '../constants/constants';
 import {assert} from '@open-wc/testing';
 
 const KERMIT: AccountInfo = {
@@ -101,6 +104,45 @@
     assert.equal(getReason(config, OTHER_ACCOUNT, change), 'Added by kermit');
   });
 
+  test('sortReviewers', () => {
+    const a1 = createAccountWithId(1);
+    a1.tags = [AccountTag.SERVICE_USER];
+    const a2 = createAccountWithId(2);
+    a2.tags = [AccountTag.SERVICE_USER];
+    const a3 = createAccountWithId(3);
+    const a4 = createAccountWithId(4);
+    const a5 = createAccountWithId(5);
+    const a6 = createAccountWithId(6);
+    const a7 = createAccountWithId(7);
+
+    const reviewers = [a1, a2, a3, a4, a5, a6, a7];
+    const change = {
+      ...createParsedChange(),
+      attention_set: {'6': {account: a6}},
+      labels: {
+        'Code-Review': {
+          all: [
+            {...a2, value: 1},
+            {...a4, value: 1},
+            {...a5, value: -1},
+          ],
+        },
+      },
+    };
+    assert.sameOrderedMembers(
+      reviewers.sort((r1, r2) => sortReviewers(r1, r2, change, a7)),
+      [
+        a7, // self
+        a6, // is in the attention set
+        a5, // human user, has voted -1
+        a4, // human user, has voted +1
+        a3, // human user, has not voted
+        a2, // service user, has voted
+        a1, // service user, has not voted
+      ]
+    );
+  });
+
   test('getMentionReason', () => {
     let comment = {
       ...createComment(),
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
index 78e78ed..58bb024 100644
--- a/polygerrit-ui/app/utils/page-wrapper-utils.ts
+++ b/polygerrit-ui/app/utils/page-wrapper-utils.ts
@@ -16,10 +16,16 @@
   redirect(url: string): void;
   replace(path: string, state: null, init: boolean, dispatch: boolean): void;
   base(url: string): void;
-  start(): void;
+  start(opts: Options): void;
+  stop(): void;
   exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
 }
 
+export interface Options {
+  popstate?: boolean;
+  dispatch?: boolean;
+}
+
 // See https://visionmedia.github.io/page.js/ for details
 export interface PageContext {
   canonicalPath: string;
@@ -37,6 +43,7 @@
   next: PageNextCallback
 ) => void;
 
-// TODO: Convert page usages to the real types and remove this file of wrapper
-// types. Also remove workarounds in rollup config.
-export const page = pagejs as unknown as Page;
+// Must only be used by gr-router!
+// TODO: Move this into gr-router. Note that there is a Google import rule
+// that would need to be modified.
+export const page = pagejs as unknown as {create(): Page};
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 8564c3f..22a9721 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -81,18 +81,56 @@
 }
 
 /**
- * Pretty-encodes a URL. Double-encodes the string, and then replaces
- *   benevolent characters for legibility.
+ * Encodes *parts* of a URL. See inline comments below for the details.
+ * Note specifically that ? & = # are encoded. So this is very close to
+ * encodeURIComponent() with some tweaks.
  */
-export function encodeURL(url: string, replaceSlashes?: boolean): string {
-  // @see Issue 4255 regarding double-encoding.
-  let output = encodeURIComponent(encodeURIComponent(url));
-  // @see Issue 4577 regarding more readable URLs.
-  output = output.replace(/%253A/g, ':');
-  output = output.replace(/%2520/g, '+');
-  if (replaceSlashes) {
-    output = output.replace(/%252F/g, '/');
-  }
+export function encodeURL(url: string): string {
+  // page.js decodes the entire URL, and then decodes once more the
+  // individual regex matching groups. It uses `decodeURIComponent()`, which
+  // will choke on singular `%` chars without two trailing digits. We prefer
+  // to not double encode *everything* (just for readaiblity and simplicity),
+  // but `%` *must* be double encoded.
+  let output = url.replaceAll('%', '%25');
+
+  // This escapes ALL characters EXCEPT:
+  // A–Z a–z 0–9 - _ . ! ~ * ' ( )
+  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
+  output = encodeURIComponent(output);
+
+  // If we would use `encodeURI()` instead of `encodeURIComponent()`, then we
+  // would also NOT encode:
+  // ; / ? : @ & = + $ , #
+  //
+  // That would be more readable, but for example ? and & have special meaning
+  // in the URL, so they must be encoded. Let's discuss all these chars and
+  // decide whether we have to encode them or not.
+  //
+  // ? & = # have to be encoded. Otherwise we might mess up the URL.
+  //
+  // : @ do not have to be encoded, because we are only dealing with path,
+  // query and fragment of the URL, not with scheme, user, host, port.
+  // For search queries it is much nicer to not encode those chars, think of
+  // searching for `owner:spearce@spearce.org`.
+  //
+  // / does not have to be encoded, because we don't care about individual path
+  // components. File path and repo names are so much nicer to read without /
+  // being encoded!
+  //
+  // + must be encoded, because we want to use it instead of %20 for spaces, see
+  // below.
+  //
+  // ; $ , probably don't have to be encoded, but we don't bother about them
+  // much, so we don't reverse the encoding here, but we don't think it would
+  // cause any harm, if we did.
+  output = output.replace(/%3A/g, ':');
+  output = output.replace(/%40/g, '@');
+  output = output.replace(/%2F/g, '/');
+
+  // page.js replaces `+` by ` ` in addition to calling `decodeURIComponent()`.
+  // So we can use `+` to increase readability.
+  output = output.replace(/%20/g, '+');
+
   return output;
 }
 
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index a014dc2..16f85dd 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -102,28 +102,21 @@
 
   suite('url encoding and decoding tests', () => {
     suite('encodeURL', () => {
-      test('double encodes', () => {
-        assert.equal(encodeURL('abc?123'), 'abc%253F123');
-        assert.equal(encodeURL('def/ghi'), 'def%252Fghi');
-        assert.equal(encodeURL('jkl'), 'jkl');
-        assert.equal(encodeURL(''), '');
+      test('does not encode alphanumeric chars', () => {
+        assert.equal(encodeURL("AZaz09-_.!~*'()"), "AZaz09-_.!~*'()");
       });
 
-      test('does not convert colons', () => {
-        assert.equal(encodeURL('mno:pqr'), 'mno:pqr');
+      test('double encodes %', () => {
+        assert.equal(encodeURL('abc%def'), 'abc%2525def');
       });
 
-      test('converts spaces to +', () => {
+      test('does not encode colon and slash', () => {
+        assert.equal(encodeURL(':/'), ':/');
+      });
+
+      test('encodes spaces as +', () => {
         assert.equal(encodeURL('words with spaces'), 'words+with+spaces');
       });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
-      });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
-      });
     });
 
     suite('singleDecodeUrl', () => {
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 98ab4b2..4b621b5 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -77,13 +77,8 @@
       {for $line, $index in $comment.lines}
         {if $index == 0}
           {if $comment.startLine != 0}
-            {$comment.link}
+            {$comment.link}{sp}:{\n}
           {/if}
-
-          // Insert a space before the newline so that Gmail does not mistakenly
-          // link the following line with the file link. See issue 9201.
-          {sp}{\n}
-
           {$comment.linePrefix}
         {else}
           {$comment.linePrefixEmpty}
diff --git a/tools/deps.bzl b/tools/deps.bzl
index ed8d65f5..28904a9 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -2,7 +2,7 @@
 
 CAFFEINE_VERS = "2.9.2"
 ANTLR_VERS = "3.5.2"
-COMMONMARK_VERS = "0.10.0"
+COMMONMARK_VERSION = "0.21.0"
 FLEXMARK_VERS = "0.50.50"
 GREENMAIL_VERS = "1.5.5"
 MAIL_VERS = "1.6.0"
@@ -14,7 +14,7 @@
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
 PROLOG_REPO = GERRIT
-GITILES_VERS = "1.0.0"
+GITILES_VERS = "1.1.0"
 GITILES_REPO = GERRIT
 
 # When updating Bouncy Castle, also update it in bazlets.
@@ -172,26 +172,26 @@
     # commonmark must match the version used in Gitiles
     maven_jar(
         name = "commonmark",
-        artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
-        sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
+        artifact = "org.commonmark:commonmark:" + COMMONMARK_VERSION,
+        sha1 = "c98f0473b17c87fe4fa2fc62a7c6523a2fe018f0",
     )
 
     maven_jar(
         name = "cm-autolink",
-        artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
-        sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
+        artifact = "org.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERSION,
+        sha1 = "55c0312cf443fa3d5af0daeeeca00d6deee3cf90",
     )
 
     maven_jar(
         name = "gfm-strikethrough",
-        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
-        sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
+        artifact = "org.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERSION,
+        sha1 = "953f4b71e133a98fcca93f3c3f4e58b895b76d1f",
     )
 
     maven_jar(
         name = "gfm-tables",
-        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
-        sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
+        artifact = "org.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERSION,
+        sha1 = "fb7d65fa89a4cfcd2f51535d2549b570cf1dbd1a",
     )
 
     maven_jar(
@@ -347,8 +347,8 @@
     # Transitive dependency of flexmark and gitiles
     maven_jar(
         name = "autolink",
-        artifact = "org.nibor.autolink:autolink:0.7.0",
-        sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
+        artifact = "org.nibor.autolink:autolink:0.10.0",
+        sha1 = "6579ea7079be461e5ffa99f33222a632711cc671",
     )
 
     maven_jar(
@@ -527,14 +527,14 @@
         artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
         attach_source = False,
         repository = GITILES_REPO,
-        sha1 = "f46833f8aa6f33ce3e443c8a414c295559eaf43e",
+        sha1 = "31c1a6e5d92b57bb2f9db24e1032145961c09a8d",
     )
 
     maven_jar(
         name = "gitiles-servlet",
         artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
         repository = GITILES_REPO,
-        sha1 = "90e107da00c2cd32490dd9ae8e3fb1ee095ea675",
+        sha1 = "c6550362c5c22d8e07edd4e2151ee12594082e76",
     )
 
     # prettify must match the version used in Gitiles