Merge "Return X-Gerrit-UpdatedRef in the response headers of WRITE requests"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 1d787cc..82e15d9 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -914,6 +914,12 @@
 +
 If direct updates are made to `All-Users`, this cache should be flushed.
 
+cache `"approvals"`::
++
+Cache entries contain approvals for a given patch set. This includes
+approvals granted on this patch set as well as approvals copied from
+earlier patch sets.
+
 cache `"adv_bases"`::
 +
 Used only for push over smart HTTP when branch level access controls
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 9d3446e..f5346c1 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -268,6 +268,46 @@
 If true, any score for the label is copied forward when a new patch
 set is uploaded. Defaults to false.
 
+[[label_copyCondition]]
+=== `label.Label-Name.copyCondition`
+
+If set, Gerrit matches patch set approvals against the provided query
+string. If the query matches, the approval is copied from one patch set
+to the next. The query language is the same as for
+link:user-search.html[other queries].
+
+This logic is triggered whenever a new patch set is uploaded.
+
+Gerrit currently supports the following predicates:
+
+==== change-kind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
+
+Matches if the diff between two patch sets was of a certain change kind.
+
+==== is:{MIN,MAX,ANY}
+
+Matches votes that are equal to the minimal or maximal voting range. Or any votes.
+
+==== approverin:`groupUUID`
+
+Matches votes granted by a user who is a member of `groupUUID`.
+
+==== uploaderin:`groupUUID`
+
+Matches votes where the new patch set was uploaded by a member of `groupUUID`.
+
+==== has:unchanged-files
+
+Matches when the new patch-set includes the same files as the old patch-set.
+
+Only 'unchanged-files' is supported for 'has'.
+
+==== Example
+
+----
+copyCondition = is:MIN OR -change-kind:REWORK OR uploaderin:dead...beef
+----
+
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
 
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
index 8f3ff88..173f709 100644
--- a/Documentation/intro-gerrit-walkthrough-github.txt
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -25,7 +25,7 @@
 Here’s how getting code reviewed and submitted with Gerrit is different from
 doing the same with GitHub:
 
-* You need the add a commit-msg hook script when you clone a repo for the first
+* You need to add a commit-msg hook script when you clone a repo for the first
 time using a snippet you can find e.g. https://gerrit-review.googlesource.com/admin/repos/gerrit[here,role=external,window=_blank];
 * Your review will be on a single commit instead of a branch. You use
 `git commit --amend` to modify a code change.
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 29bb409..e34071f 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -19,8 +19,7 @@
 
 . A Unix-based server, including any Linux flavor, MacOS, or Berkeley Software
     Distribution (BSD).
-. Java SE Runtime Environment version 1.8. Gerrit is not compatible with Java
-    9 or newer yet.
+. Java SE Runtime Environment version 11 and up.
 
 == Download Gerrit
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index df83f1a..eb38434 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3964,6 +3964,8 @@
 |`copy_any_score`|`false` if not set|
 Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
 label.
+|`copy_condition`|optional|
+See link:config-labels.html#label_copyCondition[copyCondition].
 |`copy_min_score`|`false` if not set|
 Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
 label.
@@ -4034,6 +4036,10 @@
 |`copy_any_score`|optional|
 Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
 label.
+|`copy_condition`|optional|
+See link:config-labels.html#label_copyCondition[copyCondition].
+|`unset_copy_condition`|optional|
+If true, clears the value stored in `copy_condition`.
 |`copy_min_score`|optional|
 Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
 label.
diff --git a/WORKSPACE b/WORKSPACE
index 0caf8c3..428a6a4 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -991,5 +991,5 @@
 maven_jar(
     name = "testcontainers-elasticsearch",
     artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-    sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
+    sha1 = "595e3a50f59cd3c1d281ca6c1bc4037e277a1353",
 )
diff --git a/e2e-tests/src/test/resources/hooks/commit-msg b/e2e-tests/src/test/resources/hooks/commit-msg
deleted file mode 100644
index b05a671..0000000
--- a/e2e-tests/src/test/resources/hooks/commit-msg
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/bin/sh
-#
-# Part of Gerrit Code Review (https://www.gerritcodereview.com/)
-#
-# Copyright (C) 2009 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.
-
-# avoid [[ which is not POSIX sh.
-if test "$#" != 1 ; then
-  echo "$0 requires an argument."
-  exit 1
-fi
-
-if test ! -f "$1" ; then
-  echo "file does not exist: $1"
-  exit 1
-fi
-
-if test ! -s "$1" ; then
-  echo "file is empty: $1"
-  exit 1
-fi
-
-# $RANDOM will be undefined if not using bash, so don't use set -u
-random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
-dest="$1.tmp.${random}"
-
-# Avoid the --in-place option which only appeared in Git 2.8
-# Avoid the --if-exists option which only appeared in Git 2.15
-cat "$1" \
-| git -c trailer.ifexists=doNothing interpret-trailers --trailer "Change-Id: I${random}" > "${dest}" \
-&& mv "${dest}" "$1"
diff --git a/e2e-tests/src/test/resources/hooks/commit-msg b/e2e-tests/src/test/resources/hooks/commit-msg
new file mode 120000
index 0000000..6066256
--- /dev/null
+++ b/e2e-tests/src/test/resources/hooks/commit-msg
@@ -0,0 +1 @@
+../../../../../resources/com/google/gerrit/server/tools/root/hooks/commit-msg
\ No newline at end of file
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 3e8cf3b..fd78bd8 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
@@ -103,6 +104,7 @@
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.change.BatchAbandon;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeResource;
@@ -127,6 +129,8 @@
 import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotesCommit;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.TestServerPlugin;
 import com.google.gerrit.server.project.ProjectCache;
@@ -168,6 +172,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -297,6 +302,8 @@
   protected TestRepository<InMemoryRepository> testRepo;
   protected String resourcePrefix;
   protected Description description;
+  protected GerritServer.Description testMethodDescription;
+
   protected boolean testRequiresSsh;
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
@@ -343,6 +350,13 @@
   }
 
   @After
+  public void verifyNoPiiInChangeNotes() throws RestApiException, IOException {
+    if (testMethodDescription.verifyNoPiiInChangeNotes()) {
+      verifyNoAccountDetailsInChangeNotes();
+    }
+  }
+
+  @After
   public void closeEventRecorder() {
     if (eventRecorder != null) {
       eventRecorder.close();
@@ -430,6 +444,7 @@
         GerritServer.Description.forTestClass(description, configName);
     GerritServer.Description methodDesc =
         GerritServer.Description.forTestMethod(description, configName);
+    testMethodDescription = methodDesc;
 
     testRequiresSsh = classDesc.useSshAnnotation() || methodDesc.useSshAnnotation();
     if (!testRequiresSsh) {
@@ -463,7 +478,7 @@
     toClose = Collections.synchronizedList(new ArrayList<>());
 
     admin = accountCreator.admin();
-    user = accountCreator.user();
+    user = accountCreator.user1();
 
     // Evict and reindex accounts in case tests modify them.
     reindexAccount(admin.id());
@@ -699,6 +714,58 @@
     }
   }
 
+  /**
+   * Verify that NoteDB commits do not persist user-sensitive information, by running checks for all
+   * commits in {@link RefNames#changeMetaRef} for all changes, created during the test.
+   *
+   * <p>These tests prevent regression, assuming appropriate test coverage for new features. The
+   * verification is disabled by default and can be enabled using {@link VerifyNoPiiInChangeNotes}
+   * annotation either on test class or method.
+   */
+  protected void verifyNoAccountDetailsInChangeNotes() throws RestApiException, IOException {
+    List<ChangeInfo> allChanges = gApi.changes().query().get();
+
+    List<AccountState> allAccounts = accounts.all();
+    for (ChangeInfo change : allChanges) {
+      try (Repository repo = repoManager.openRepository(Project.nameKey(change.project))) {
+        String metaRefName =
+            RefNames.changeMetaRef(Change.Id.tryParse(change._number.toString()).get());
+        ObjectId metaTip = repo.getRefDatabase().exactRef(metaRefName).getObjectId();
+        ChangeNotesRevWalk revWalk = ChangeNotesCommit.newRevWalk(repo);
+        revWalk.reset();
+        revWalk.markStart(revWalk.parseCommit(metaTip));
+        ChangeNotesCommit commit;
+        while ((commit = revWalk.next()) != null) {
+          String fullMessage = commit.getFullMessage();
+          for (AccountState accountState : allAccounts) {
+            Account account = accountState.account();
+            assertThat(fullMessage).doesNotContain(account.getName());
+            if (account.fullName() != null) {
+              assertThat(fullMessage).doesNotContain(account.fullName());
+            }
+            if (account.displayName() != null) {
+              assertThat(fullMessage).doesNotContain(account.displayName());
+            }
+            if (account.preferredEmail() != null) {
+              assertThat(fullMessage).doesNotContain(account.preferredEmail());
+            }
+            if (accountState.userName().isPresent()) {
+              assertThat(fullMessage).doesNotContain(accountState.userName().get());
+            }
+            List<String> allEmails =
+                accountState.externalIds().stream()
+                    .map(ExternalId::email)
+                    .filter(Objects::nonNull)
+                    .collect(toImmutableList());
+            for (String email : allEmails) {
+              assertThat(fullMessage).doesNotContain(email);
+            }
+          }
+        }
+      }
+    }
+  }
+
   protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
     return testRepo.branch("HEAD").commit().insertChangeId();
   }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 127f92b..aa13339 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -145,8 +145,8 @@
     return create("admin2", "admin2@example.com", "Administrator2", null, "Administrators");
   }
 
-  public TestAccount user() throws Exception {
-    return create("user", "user@example.com", "User", null);
+  public TestAccount user1() throws Exception {
+    return create("user1", "user1@example.com", "User1", null);
   }
 
   public TestAccount user2() throws Exception {
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 1ea9ebc..93c1237 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -111,6 +111,8 @@
   public abstract static class Description {
     public static Description forTestClass(
         org.junit.runner.Description testDesc, String configName) {
+      VerifyNoPiiInChangeNotes verifyNoPiiInChangeNotes =
+          get(VerifyNoPiiInChangeNotes.class, testDesc.getTestClass());
       return new AutoValue_GerritServer_Description(
           testDesc,
           configName,
@@ -119,6 +121,7 @@
           has(Sandboxed.class, testDesc.getTestClass()),
           has(SkipProjectClone.class, testDesc.getTestClass()),
           has(UseSsh.class, testDesc.getTestClass()),
+          verifyNoPiiInChangeNotes != null && verifyNoPiiInChangeNotes.value(),
           false, // @UseSystemTime is only valid on methods.
           get(UseClockStep.class, testDesc.getTestClass()),
           get(UseTimezone.class, testDesc.getTestClass()),
@@ -138,6 +141,11 @@
         // on class level.
         useClockStep = get(UseClockStep.class, testDesc.getTestClass());
       }
+      VerifyNoPiiInChangeNotes verifyNoPiiInChangeNotes =
+          testDesc.getAnnotation(VerifyNoPiiInChangeNotes.class);
+      if (verifyNoPiiInChangeNotes == null) {
+        verifyNoPiiInChangeNotes = get(VerifyNoPiiInChangeNotes.class, testDesc.getTestClass());
+      }
 
       return new AutoValue_GerritServer_Description(
           testDesc,
@@ -153,6 +161,7 @@
               || has(SkipProjectClone.class, testDesc.getTestClass()),
           testDesc.getAnnotation(UseSsh.class) != null
               || has(UseSsh.class, testDesc.getTestClass()),
+          verifyNoPiiInChangeNotes != null && verifyNoPiiInChangeNotes.value(),
           testDesc.getAnnotation(UseSystemTime.class) != null,
           useClockStep,
           testDesc.getAnnotation(UseTimezone.class) != null
@@ -198,6 +207,8 @@
 
     abstract boolean useSshAnnotation();
 
+    abstract boolean verifyNoPiiInChangeNotes();
+
     boolean useSsh() {
       return useSshAnnotation() && SshMode.useSsh();
     }
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 67e26ec..d46fb78 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -282,6 +282,11 @@
     return this;
   }
 
+  public PushOneCommit noParent() throws Exception {
+    commitBuilder.noParents();
+    return this;
+  }
+
   public PushOneCommit addSymlink(String path, String target) throws Exception {
     RevBlob blobId = testRepo.blob(target);
     commitBuilder.edit(
diff --git a/java/com/google/gerrit/acceptance/VerifyNoPiiInChangeNotes.java b/java/com/google/gerrit/acceptance/VerifyNoPiiInChangeNotes.java
new file mode 100644
index 0000000..1bdaa6e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/VerifyNoPiiInChangeNotes.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2021 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.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation for the acceptance tests, inherited from {@link AbstractDaemonTest}, to enable
+ * verification that NoteDB commits do not persist user-sensitive information. See {@link
+ * AbstractDaemonTest#verifyNoAccountDetailsInChangeNotes}.
+ *
+ * <p>Disabled by default, can be enabled per test class/test method.
+ */
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface VerifyNoPiiInChangeNotes {
+  boolean value() default false;
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
new file mode 100644
index 0000000..cb987da
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
@@ -0,0 +1,348 @@
+// Copyright (C) 2021 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.acceptance.testsuite.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** Helper to create changes of a certain {@link ChangeKind}. */
+public class ChangeKindCreator {
+  private GerritApi gApi;
+  private PushOneCommit.Factory pushFactory;
+  private RequestScopeOperations requestScopeOperations;
+  private ProjectOperations projectOperations;
+
+  @Inject
+  private ChangeKindCreator(
+      GerritApi gApi,
+      PushOneCommit.Factory pushFactory,
+      RequestScopeOperations requestScopeOperations,
+      ProjectOperations projectOperations) {
+    this.gApi = gApi;
+    this.pushFactory = pushFactory;
+    this.requestScopeOperations = requestScopeOperations;
+    this.projectOperations = projectOperations;
+  }
+
+  /** Creates a change with the given {@link ChangeKind} and returns the change id. */
+  public String createChange(
+      ChangeKind kind, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    switch (kind) {
+      case NO_CODE_CHANGE:
+      case REWORK:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return createChange(testRepo, user).getChangeId();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return createChangeForMergeCommit(testRepo, user);
+      default:
+        throw new IllegalStateException("unexpected change kind: " + kind);
+    }
+  }
+
+  /** Updates a change with the given {@link ChangeKind}. */
+  public void updateChange(
+      String changeId,
+      ChangeKind changeKind,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    switch (changeKind) {
+      case NO_CODE_CHANGE:
+        noCodeChange(changeId, testRepo, user, project);
+        return;
+      case REWORK:
+        rework(changeId, testRepo, user, project);
+        return;
+      case TRIVIAL_REBASE:
+        trivialRebase(changeId, testRepo, user, project);
+        return;
+      case MERGE_FIRST_PARENT_UPDATE:
+        updateFirstParent(changeId, testRepo, user);
+        return;
+      case NO_CHANGE:
+        noChange(changeId, testRepo, user, project);
+        return;
+      default:
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
+    }
+  }
+
+  /**
+   * Creates a cherry pick of the provided change with the given {@link ChangeKind} and returns the
+   * change id.
+   */
+  public String cherryPick(
+      String changeId,
+      ChangeKind changeKind,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    switch (changeKind) {
+      case REWORK:
+      case TRIVIAL_REBASE:
+        break;
+      case NO_CODE_CHANGE:
+      case NO_CHANGE:
+      case MERGE_FIRST_PARENT_UPDATE:
+      default:
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
+    }
+
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                user.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                "other.txt",
+                "new content " + System.nanoTime())
+            .to("refs/for/master");
+    r.assertOkStatus();
+    vote(user, r.getChangeId(), 2, 1);
+    merge(r);
+
+    String subject =
+        ChangeKind.TRIVIAL_REBASE.equals(changeKind)
+            ? PushOneCommit.SUBJECT
+            : "Reworked change " + System.nanoTime();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "master";
+    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
+    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
+    return c.changeId;
+  }
+
+  /** Creates a change that is a merge {@link ChangeKind} and returns the change id. */
+  public String createChangeForMergeCommit(
+      TestRepository<InMemoryRepository> testRepo, TestAccount user) throws Exception {
+    ObjectId initial = testRepo.getRepository().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1", testRepo, user);
+
+    testRepo.reset(initial);
+    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2", testRepo, user);
+
+    testRepo.reset(parent1.getCommit());
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo);
+    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+    return result.getChangeId();
+  }
+
+  /** Update the first parent of a merge. */
+  public void updateFirstParent(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
+
+    testRepo.reset(parent1);
+    PushOneCommit.Result newParent1 =
+        createChange("new parent 1", "p1-1.txt", "content 1-1", testRepo, user);
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.MERGE_FIRST_PARENT_UPDATE);
+  }
+
+  /** Update the second parent of a merge. */
+  public void updateSecondParent(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
+
+    testRepo.reset(parent2);
+    PushOneCommit.Result newParent2 =
+        createChange("new parent 2", "p2-2.txt", "content 2-2", testRepo, user);
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.REWORK);
+  }
+
+  private void noCodeChange(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message("New subject " + System.nanoTime())
+        .author(user.newIdent())
+        .committer(new PersonIdent(user.newIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.NO_CODE_CHANGE);
+  }
+
+  private void noChange(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
+
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message(commitMessage)
+        .author(user.newIdent())
+        .committer(new PersonIdent(user.newIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  private void rework(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            user.newIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "new content " + System.nanoTime(),
+            changeId);
+    push.to("refs/for/master").assertOkStatus();
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.REWORK);
+  }
+
+  private void trivialRebase(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    PushOneCommit push =
+        pushFactory.create(
+            user.newIdent(),
+            testRepo,
+            "Other Change",
+            "a" + System.nanoTime() + ".txt",
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
+    revision.review(in);
+    revision.submit();
+
+    gApi.changes().id(changeId).current().rebase();
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.TRIVIAL_REBASE);
+  }
+
+  private ChangeKind getChangeKind(String changeId) throws Exception {
+    ChangeInfo c = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
+    return c.revisions.get(c.currentRevision).kind;
+  }
+
+  private PushOneCommit.Result createChange(
+      TestRepository<InMemoryRepository> testRepo, TestAccount user) throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    return result;
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .get(
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.CURRENT_REVISION,
+            ListChangesOption.CURRENT_COMMIT);
+  }
+
+  private PushOneCommit.Result createChange(
+      String subject,
+      String fileName,
+      String content,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master");
+  }
+
+  private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput in =
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  private void merge(PushOneCommit.Result r) throws Exception {
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index 9649642..d254752 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -24,6 +24,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 @AutoValue
 public abstract class LabelType {
@@ -128,6 +129,8 @@
 
   public abstract boolean isCanOverride();
 
+  public abstract Optional<String> getCopyCondition();
+
   @Nullable
   public abstract ImmutableList<String> getRefPatterns();
 
@@ -239,6 +242,8 @@
 
     public abstract Builder setCopyAnyScore(boolean copyAnyScore);
 
+    public abstract Builder setCopyCondition(@Nullable String copyCondition);
+
     public abstract Builder setCopyMinScore(boolean copyMinScore);
 
     public abstract Builder setCopyMaxScore(boolean copyMaxScore);
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 36f7b53..df03fd5 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -29,23 +29,23 @@
   /**
    * Expression of the condition that makes the requirement applicable. The expression should be
    * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
-   * irrelevant for the change (i.e. {@link #blockingExpression()} and {@link #overrideExpression()}
-   * become irrelevant).
+   * irrelevant for the change (i.e. {@link #submittabilityExpression()} and {@link
+   * #overrideExpression()} become irrelevant).
    *
    * <p>An empty {@link Optional} indicates that the requirement is applicable for any change.
    */
   public abstract Optional<SubmitRequirementExpression> applicabilityExpression();
 
   /**
-   * Expression of the condition that blocks the submission of a change. The expression should be
-   * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
+   * Expression of the condition that allows the submission of a change. The expression should be
+   * evaluated for a specific {@link Change} and if it returns true, the requirement becomes
    * fulfilled for the change.
    */
-  public abstract SubmitRequirementExpression blockingExpression();
+  public abstract SubmitRequirementExpression submittabilityExpression();
 
   /**
    * Expression that, if evaluated to true, causes the submit requirement to be fulfilled,
-   * regardless of the blocking expression. This expression should be evaluated for a specific
+   * regardless of the submittability expression. This expression should be evaluated for a specific
    * {@link Change}.
    *
    * <p>An empty {@link Optional} indicates that the requirement is not overridable.
@@ -72,7 +72,8 @@
     public abstract Builder setApplicabilityExpression(
         Optional<SubmitRequirementExpression> applicabilityExpression);
 
-    public abstract Builder setBlockingExpression(SubmitRequirementExpression blockingExpression);
+    public abstract Builder setSubmittabilityExpression(
+        SubmitRequirementExpression submittabilityExpression);
 
     public abstract Builder setOverrideExpression(
         Optional<SubmitRequirementExpression> overrideExpression);
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
index 7b31304..c978347 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpression.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -19,12 +19,7 @@
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
 
-/**
- * Describe a applicability, blocking or override expression of a {@link SubmitRequirement}.
- *
- * <p>TODO: Store the tree representation of the parsed expression internally and throw an exception
- * upon creation if the expression syntax is invalid.
- */
+/** Describe a applicability, blocking or override expression of a {@link SubmitRequirement}. */
 @AutoValue
 public abstract class SubmitRequirementExpression {
 
@@ -45,5 +40,5 @@
   }
 
   /** Returns the underlying String representing this {@link SubmitRequirementExpression}. */
-  public abstract String expression();
+  public abstract String expressionString();
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
new file mode 100644
index 0000000..94c0e91
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2021 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.Optional;
+
+/** Result of evaluating a submit requirement expression on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementExpressionResult {
+
+  /**
+   * Entity detailing the result of evaluating a Submit requirement expression. Contains an empty
+   * {@link Optional} if {@link #status()} is equal to {@link Status#ERROR}.
+   */
+  public abstract Optional<PredicateResult> predicateResult();
+
+  public abstract Optional<String> errorMessage();
+
+  public Status status() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().status() ? Status.PASS : Status.FAIL;
+    }
+    return Status.ERROR;
+  }
+
+  public static SubmitRequirementExpressionResult create(PredicateResult predicateResult) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        Optional.of(predicateResult), Optional.empty());
+  }
+
+  public static SubmitRequirementExpressionResult error(String errorMessage) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        Optional.empty(), Optional.of(errorMessage));
+  }
+
+  /**
+   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. If
+   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
+   */
+  public ImmutableList<PredicateResult> getPassingAtoms() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().getAtoms(/* status= */ true);
+    }
+    return ImmutableList.of();
+  }
+
+  /**
+   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. If
+   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
+   */
+  public ImmutableList<PredicateResult> getFailingAtoms() {
+    if (predicateResult().isPresent()) {
+      return predicateResult().get().getAtoms(/* status= */ false);
+    }
+    return ImmutableList.of();
+  }
+
+  public enum Status {
+    /** Submit requirement expression is fulfilled for a given change. */
+    PASS,
+
+    /** Submit requirement expression is failing for a given change. */
+    FAIL,
+
+    /** Submit requirement expression contains invalid syntax and is not parsable. */
+    ERROR
+  }
+
+  /**
+   * Entity detailing the result of evaluating a predicate.
+   *
+   * <p>Example - branch:refs/heads/foo and has:unresolved
+   *
+   * <p>The above predicate is an "And" predicate having two child predicates:
+   *
+   * <ul>
+   *   <li>branch:refs/heads/foo
+   *   <li>has:unresolved
+   * </ul>
+   *
+   * <p>Each child predicate as well as the parent contains the result of its evaluation.
+   */
+  @AutoValue
+  public abstract static class PredicateResult {
+    abstract ImmutableList<PredicateResult> childPredicateResults();
+
+    abstract String predicateString();
+
+    /** true if the predicate is passing for a given change. */
+    abstract boolean status();
+
+    /**
+     * Returns the list of leaf {@link PredicateResult} whose {@link #status()} is equal to the
+     * {@code status} parameter.
+     */
+    ImmutableList<PredicateResult> getAtoms(boolean status) {
+      ImmutableList.Builder<PredicateResult> atomsList = ImmutableList.builder();
+      getAtomsRecursively(atomsList, status);
+      return atomsList.build();
+    }
+
+    private void getAtomsRecursively(ImmutableList.Builder<PredicateResult> list, boolean status) {
+      if (childPredicateResults().isEmpty() && status() == status) {
+        list.add(this);
+        return;
+      }
+      childPredicateResults().forEach(c -> c.getAtomsRecursively(list, status));
+    }
+
+    public static Builder builder() {
+      return new AutoValue_SubmitRequirementExpressionResult_PredicateResult.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
+
+      protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
+
+      public abstract Builder predicateString(String value);
+
+      public abstract Builder status(boolean value);
+
+      public Builder addChildPredicateResult(PredicateResult result) {
+        childPredicateResultsBuilder().add(result);
+        return this;
+      }
+
+      public abstract PredicateResult build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
new file mode 100644
index 0000000..7b4d609
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2021 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import java.util.Optional;
+
+/** Result of evaluating a {@link SubmitRequirement} on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementResult {
+  /** Result of evaluating a {@link SubmitRequirement#applicabilityExpression()} on a change. */
+  public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
+
+  /**
+   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} ()} on a change.
+   */
+  public abstract SubmitRequirementExpressionResult submittabilityExpressionResult();
+
+  /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
+  public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
+
+  @Memoized
+  public Status status() {
+    if (assertError(submittabilityExpressionResult())
+        || assertError(applicabilityExpressionResult())
+        || assertError(overrideExpressionResult())) {
+      return Status.ERROR;
+    } else if (assertFail(applicabilityExpressionResult())) {
+      return Status.NOT_APPLICABLE;
+    } else if (assertPass(overrideExpressionResult())) {
+      return Status.OVERRIDDEN;
+    } else if (assertPass(submittabilityExpressionResult())) {
+      return Status.SATISFIED;
+    } else {
+      return Status.UNSATISFIED;
+    }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_SubmitRequirementResult.Builder();
+  }
+
+  public enum Status {
+    /** Submit requirement is fulfilled. */
+    SATISFIED,
+
+    /**
+     * Submit requirement is not satisfied. Happens when {@link
+     * SubmitRequirement#submittabilityExpression()} evaluates to false.
+     */
+    UNSATISFIED,
+
+    /**
+     * Submit requirement is overridden. Happens when {@link SubmitRequirement#overrideExpression()}
+     * evaluates to true.
+     */
+    OVERRIDDEN,
+
+    /**
+     * Submit requirement is not applicable for a given change. Happens when {@link
+     * SubmitRequirement#applicabilityExpression()} evaluates to false.
+     */
+    NOT_APPLICABLE,
+
+    /**
+     * Any of the applicability, blocking or override expressions contain invalid syntax and are not
+     * parsable.
+     */
+    ERROR
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder applicabilityExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+
+    public abstract Builder overrideExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract SubmitRequirementResult build();
+  }
+
+  private boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private boolean assertPass(SubmitRequirementExpressionResult expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  private boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
+  }
+
+  private boolean assertError(SubmitRequirementExpressionResult expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
+  }
+
+  private boolean assertStatus(
+      SubmitRequirementExpressionResult expressionResult,
+      SubmitRequirementExpressionResult.Status status) {
+    return expressionResult.status() == status;
+  }
+
+  private boolean assertStatus(
+      Optional<SubmitRequirementExpressionResult> expressionResult,
+      SubmitRequirementExpressionResult.Status status) {
+    return expressionResult.isPresent() && assertStatus(expressionResult.get(), status);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index 647dead..0fff0ba 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -70,12 +70,26 @@
 
   @SuppressWarnings("unchecked") // reflection is used to construct instances of T
   private static <T> T getAdded(T oldValue, T newValue) {
+    if (newValue instanceof Collection) {
+      List result = getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
+      return (T) result;
+    }
+
+    if (newValue instanceof Map) {
+      Map result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
+      return (T) result;
+    }
+
     T toPopulate = (T) construct(newValue.getClass());
     if (toPopulate == null) {
       return null;
     }
 
     for (Field field : newValue.getClass().getDeclaredFields()) {
+      if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) {
+        continue;
+      }
+
       Object newFieldObj = get(field, newValue);
       if (oldValue == null || newFieldObj == null) {
         set(field, toPopulate, newFieldObj);
@@ -89,13 +103,8 @@
 
       if (isSimple(field.getType()) || oldFieldObj == null) {
         set(field, toPopulate, newFieldObj);
-      } else if (newFieldObj instanceof Collection) {
-        set(
-            field,
-            toPopulate,
-            getAddedForCollection((Collection<?>) oldFieldObj, (Collection<?>) newFieldObj));
-      } else if (newFieldObj instanceof Map) {
-        set(field, toPopulate, getAddedForMap((Map<?, ?>) oldFieldObj, (Map<?, ?>) newFieldObj));
+      } else if (newFieldObj instanceof Collection || newFieldObj instanceof Map) {
+        set(field, toPopulate, getAdded(oldFieldObj, newFieldObj));
       } else {
         // Recurse to set all fields in the non-primitive object.
         set(field, toPopulate, getAdded(oldFieldObj, newFieldObj));
@@ -143,6 +152,9 @@
 
   private static ImmutableList<Object> getAdditions(
       Collection<?> oldCollection, Collection<?> newCollection) {
+    if (oldCollection == null)
+      return newCollection != null ? ImmutableList.copyOf(newCollection) : null;
+
     Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
     oldCollection.forEach(
         v -> {
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index 9a6d086..6f733d6 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -26,6 +26,7 @@
   public List<String> branches;
   public Boolean canOverride;
   public Boolean copyAnyScore;
+  public String copyCondition;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
   public Boolean copyAllScoresIfListOfFilesDidNotChange;
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 87cae86..38b76c1 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -25,6 +25,8 @@
   public List<String> branches;
   public Boolean canOverride;
   public Boolean copyAnyScore;
+  public String copyCondition;
+  public Boolean unsetCopyCondition;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
   public Boolean copyAllScoresIfListOfFilesDidNotChange;
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index aac6682..7bbe70b 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -14,11 +14,30 @@
 
 package com.google.gerrit.index.query;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Ints;
+import com.google.common.primitives.Longs;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.StreamSupport;
 
 /** Predicate that is mapped to a field in the index. */
-public abstract class IndexPredicate<I> extends OperatorPredicate<I> {
+public abstract class IndexPredicate<I> extends OperatorPredicate<I> implements Matchable<I> {
+  /**
+   * Text segmentation to be applied to both the query string and the indexed field for full-text
+   * queries. This is inspired by http://unicode.org/reports/tr29/ which is what Lucene uses, but
+   * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
+   * spec.
+   */
+  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-\\/_\n"));
+
   private final FieldDef<I, ?> def;
 
   protected IndexPredicate(FieldDef<I, ?> def, String value) {
@@ -38,4 +57,70 @@
   public FieldType<?> getType() {
     return def.getType();
   }
+
+  /**
+   * This method matches documents without calling an index subsystem. For primitive fields (e.g.
+   * integer, long) , the matching logic is consistent across this method and all known index
+   * implementations. For text fields (i.e. prefix and full-text) the semantics vary between this
+   * implementation and known index implementations:
+   * <li>Prefix: Lucene as well as {@link #match(I)} matches terms as true prefixes (prefix:foo ->
+   *     `foo bar` matches, but `baz foo bar` does not match). The index implementation at Google
+   *     tokenizes both the query and the indexed text and matches tokens individually (prefix:fo ba
+   *     -> `baz foo bar` matches).
+   * <li>Full text: Lucene uses a {@code PhraseQuery} to search for terms in full text fields
+   *     in-order. The index implementation at Google as well as {@link #match(I)} tokenizes both
+   *     the query and the indexed text and matches tokens individually.
+   *
+   * @return true if the predicate matches the provided {@link I}.
+   */
+  @Override
+  public boolean match(I doc) {
+    if (getField().isRepeatable()) {
+      Iterable<Object> values = (Iterable<Object>) getField().get(doc);
+      for (Object v : values) {
+        if (matchesSingleObject(v)) {
+          return true;
+        }
+      }
+      return false;
+    } else {
+      return matchesSingleObject(getField().get(doc));
+    }
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  private boolean matchesSingleObject(Object fieldValueFromObject) {
+    String fieldTypeName = getField().getType().getName();
+    if (fieldTypeName.equals(FieldType.INTEGER.getName())) {
+      return Objects.equals(fieldValueFromObject, Ints.tryParse(value));
+    } else if (fieldTypeName.equals(FieldType.EXACT.getName())) {
+      return Objects.equals(fieldValueFromObject, value);
+    } else if (fieldTypeName.equals(FieldType.LONG.getName())) {
+      return Objects.equals(fieldValueFromObject, Longs.tryParse(value));
+    } else if (fieldTypeName.equals(FieldType.PREFIX.getName())) {
+      return String.valueOf(fieldValueFromObject).startsWith(value);
+    } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
+      Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
+      Set<String> tokenizedValue = tokenizeString(value);
+      return !Sets.intersection(tokenizedField, tokenizedValue).isEmpty();
+    } else if (fieldTypeName.equals(FieldType.STORED_ONLY.getName())) {
+      throw new IllegalStateException("can't filter for storedOnly field " + getField().getName());
+    } else if (fieldTypeName.equals(FieldType.TIMESTAMP.getName())) {
+      throw new IllegalStateException("timestamp queries must be handled in subclasses");
+    } else if (fieldTypeName.equals(FieldType.INTEGER_RANGE.getName())) {
+      throw new IllegalStateException("integer range queries must be handled in subclasses");
+    } else {
+      throw new IllegalStateException("unrecognized field " + fieldTypeName);
+    }
+  }
+
+  private static ImmutableSet<String> tokenizeString(String value) {
+    return StreamSupport.stream(FULL_TEXT_SPLITTER.split(value.toLowerCase()).spliterator(), false)
+        .filter(s -> !s.trim().isEmpty())
+        .collect(toImmutableSet());
+  }
 }
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 6780867..850c4a5 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -31,6 +31,7 @@
 
   protected abstract Integer getValueInt(T object);
 
+  @Override
   public boolean match(T object) {
     Integer valueInt = getValueInt(object);
     if (valueInt == null) {
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 2cfc49f..30de2f5 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -79,6 +80,7 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+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.restapi.group.GroupModule;
@@ -171,6 +173,7 @@
     modules.add(new GroupModule());
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
+    modules.add(ApprovalCacheImpl.module());
     modules.add(DefaultPreferencesCacheImpl.module());
     modules.add(GroupCacheImpl.module());
     modules.add(GroupIncludeCacheImpl.module());
@@ -181,6 +184,7 @@
     modules.add(ServiceUserClassifierImpl.module());
     modules.add(TagCache.module());
     modules.add(PureRevertCache.module());
+    modules.add(new ApprovalModule());
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 404906d..f9195c0 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -131,6 +131,7 @@
         "//lib/ow2:ow2-asm-util",
         "//lib/prolog:runtime",
         "//proto:cache_java_proto",
+        "//proto:entities_java_proto",
     ],
 )
 
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 005ae3b..d60bc8f 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -149,8 +150,7 @@
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
 
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
-    for (PatchSetApproval ap :
-        approvalsUtil.byPatchSet(notes, change.currentPatchSetId(), null, null)) {
+    for (PatchSetApproval ap : approvalsUtil.byPatchSet(notes, change.currentPatchSetId())) {
       LabelType type = projectState.getLabelTypes(notes).byLabel(ap.label());
       if (type != null && ap.value() == 1 && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 2665b9a..75a2b38 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -531,6 +531,19 @@
     return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
   }
 
+  /**
+   * As opposed to {@link #resolve}, the returned result includes all inactive accounts for the
+   * input search.
+   *
+   * <p>This can be used to resolve Gerrit Account from email to its {@link Account.Id}, to make
+   * sure that if {@link Account} with such email exists in Gerrit (even inactive), user data (email
+   * address) won't be recorded as it is, but instead will be stored as a link to the corresponding
+   * Gerrit Account.
+   */
+  public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(input, searchers, visibilitySupplierCanSee(), all());
+  }
+
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
     return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
   }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index ab96c6b..764c46d 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -57,9 +57,9 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -642,7 +642,7 @@
         ListMultimapBuilder.treeKeys().arrayListValues().build();
     try {
       Iterable<PatchSetApproval> approvals =
-          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id(), null, null);
+          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id());
       AccountLoader accountLoader =
           accountLoaderFactory.create(
               EnumSet.of(
diff --git a/java/com/google/gerrit/server/approval/ApprovalCache.java b/java/com/google/gerrit/server/approval/ApprovalCache.java
new file mode 100644
index 0000000..5637249
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/ApprovalCache.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 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.approval;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.notedb.ChangeNotes;
+
+/**
+ * Cache that holds approvals per patch set and NoteDb state. This includes approvals copied forward
+ * from older patch sets.
+ */
+public interface ApprovalCache {
+  /** Returns {@link PatchSetApproval}s for the given patch set. */
+  Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId);
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
new file mode 100644
index 0000000..93099eb
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2021 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.approval;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.protobuf.ByteString;
+import java.util.concurrent.ExecutionException;
+
+/** @see ApprovalCache */
+public class ApprovalCacheImpl implements ApprovalCache {
+  private static final String CACHE_NAME = "approvals";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ApprovalCache.class).to(ApprovalCacheImpl.class);
+        persist(
+                CACHE_NAME,
+                Cache.PatchSetApprovalsKeyProto.class,
+                Cache.AllPatchSetApprovalsProto.class)
+            .version(1)
+            .loader(Loader.class)
+            .keySerializer(new ProtobufSerializer<>(Cache.PatchSetApprovalsKeyProto.parser()))
+            .valueSerializer(new ProtobufSerializer<>(Cache.AllPatchSetApprovalsProto.parser()));
+      }
+    };
+  }
+
+  private final LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto>
+      cache;
+
+  @Inject
+  ApprovalCacheImpl(
+      @Named(CACHE_NAME)
+          LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId) {
+    try {
+      return fromProto(
+          cache.get(
+              Cache.PatchSetApprovalsKeyProto.newBuilder()
+                  .setChangeId(notes.getChangeId().get())
+                  .setPatchSetId(psId.get())
+                  .setProject(notes.getProjectName().get())
+                  .setId(
+                      ByteString.copyFrom(
+                          ObjectIdCacheSerializer.INSTANCE.serialize(notes.getMetaId())))
+                  .build()));
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Singleton
+  static class Loader
+      extends CacheLoader<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> {
+    private final ApprovalInference approvalInference;
+    private final ChangeNotes.Factory changeNotesFactory;
+
+    @Inject
+    Loader(ApprovalInference approvalInference, ChangeNotes.Factory changeNotesFactory) {
+      this.approvalInference = approvalInference;
+      this.changeNotesFactory = changeNotesFactory;
+    }
+
+    @Override
+    public Cache.AllPatchSetApprovalsProto load(Cache.PatchSetApprovalsKeyProto key)
+        throws Exception {
+      Change.Id changeId = Change.id(key.getChangeId());
+      return toProto(
+          approvalInference.forPatchSet(
+              changeNotesFactory.createChecked(
+                  Project.nameKey(key.getProject()),
+                  changeId,
+                  ObjectIdCacheSerializer.INSTANCE.deserialize(key.getId().toByteArray())),
+              PatchSet.id(changeId, key.getPatchSetId()),
+              null
+              /* revWalk= */ ,
+              null
+              /* repoConfig= */ ));
+    }
+  }
+
+  private static Iterable<PatchSetApproval> fromProto(Cache.AllPatchSetApprovalsProto proto) {
+    ImmutableList.Builder<PatchSetApproval> builder = ImmutableList.builder();
+    for (Entities.PatchSetApproval psa : proto.getApprovalList()) {
+      builder.add(PatchSetApprovalProtoConverter.INSTANCE.fromProto(psa));
+    }
+    return builder.build();
+  }
+
+  private static Cache.AllPatchSetApprovalsProto toProto(Iterable<PatchSetApproval> autoValue) {
+    Cache.AllPatchSetApprovalsProto.Builder builder = Cache.AllPatchSetApprovalsProto.newBuilder();
+    for (PatchSetApproval psa : autoValue) {
+      builder.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(psa));
+    }
+    return builder.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
similarity index 87%
rename from java/com/google/gerrit/server/ApprovalInference.java
rename to java/com/google/gerrit/server/approval/ApprovalInference.java
index 675c470..0185598 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalInference.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
@@ -26,12 +26,12 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.logging.Metadata;
@@ -44,6 +44,11 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.approval.ApprovalContext;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.query.approval.ListOfFilesUnchangedPredicate;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -61,24 +66,33 @@
  * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
  */
 @Singleton
-public class ApprovalInference {
+class ApprovalInference {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
   private final PatchListCache patchListCache;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final OneOffRequestContext requestContext;
+  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
 
   @Inject
   ApprovalInference(
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
       LabelNormalizer labelNormalizer,
-      PatchListCache patchListCache) {
+      PatchListCache patchListCache,
+      ApprovalQueryBuilder approvalQueryBuilder,
+      OneOffRequestContext requestContext,
+      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
     this.patchListCache = patchListCache;
+    this.approvalQueryBuilder = approvalQueryBuilder;
+    this.requestContext = requestContext;
+    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
   }
 
   /**
@@ -105,7 +119,7 @@
     }
   }
 
-  private static boolean canCopy(
+  private boolean canCopyBasedOnBooleanLabelConfigs(
       ProjectState project,
       PatchSetApproval psa,
       PatchSet.Id psId,
@@ -172,12 +186,7 @@
           project.getName());
       return true;
     } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && patchList.getPatches().stream()
-            .noneMatch(
-                p ->
-                    p.getChangeType() == ChangeType.ADDED
-                        || p.getChangeType() == ChangeType.DELETED
-                        || p.getChangeType() == ChangeType.RENAMED)) {
+        && listOfFilesUnchangedPredicate.match(patchList)) {
       logger.atFine().log(
           "approval %d on label %s of patch set %d of change %d can be copied"
               + " to patch set %d because the label has set "
@@ -309,6 +318,31 @@
     }
   }
 
+  private boolean canCopyBasedOnCopyCondition(
+      ChangeNotes changeNotes,
+      PatchSetApproval psa,
+      PatchSet.Id psId,
+      LabelType type,
+      ChangeKind changeKind) {
+    if (!type.getCopyCondition().isPresent()) {
+      return false;
+    }
+    ApprovalContext ctx = ApprovalContext.create(changeNotes, psa, psId, changeKind);
+    try {
+      // Use a request context to run checks as an internal user with expanded visibility. This is
+      // so that the output of the copy condition does not depend on who is running the current
+      // request (e.g. a group used in this query might not be visible to the person sending this
+      // request).
+      try (ManualRequestContext ignored = requestContext.open()) {
+        return approvalQueryBuilder.parse(type.getCopyCondition().get()).asMatchable().match(ctx);
+      }
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log(
+          "Unable to copy label because config is invalid. This should have been caught before.");
+      return false;
+    }
+  }
+
   private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
       ChangeNotes notes,
       ProjectState project,
@@ -380,7 +414,8 @@
       if (patchList == null && type != null && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
         patchList = getPatchList(project, ps, priorPatchSet);
       }
-      if (!canCopy(project, psa, ps.id(), kind, type, patchList)) {
+      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type, patchList)
+          && !canCopyBasedOnCopyCondition(notes, psa, ps.id(), type, kind)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
similarity index 95%
rename from java/com/google/gerrit/server/ApprovalsUtil.java
rename to java/com/google/gerrit/server/approval/ApprovalsUtil.java
index 411768d..b1e85e9 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
@@ -39,6 +39,9 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -94,16 +97,19 @@
   private final ApprovalInference approvalInference;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final ApprovalCache approvalCache;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
       ApprovalInference approvalInference,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalCache approvalCache) {
     this.approvalInference = approvalInference;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.approvalCache = approvalCache;
   }
 
   /**
@@ -338,6 +344,10 @@
     return approvalInference.forPatchSet(notes, psId, rw, repoConfig);
   }
 
+  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+    return approvalCache.get(notes, psId);
+  }
+
   public Iterable<PatchSetApproval> byPatchSetUser(
       ChangeNotes notes,
       PatchSet.Id psId,
@@ -347,6 +357,11 @@
     return filterApprovals(byPatchSet(notes, psId, rw, repoConfig), accountId);
   }
 
+  public Iterable<PatchSetApproval> byPatchSetUser(
+      ChangeNotes notes, PatchSet.Id psId, Account.Id accountId) {
+    return filterApprovals(byPatchSet(notes, psId), accountId);
+  }
+
   public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
     if (c == null) {
       return null;
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index 4627cdb..c00961f 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Converter;
 import com.google.common.base.Enums;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Shorts;
 import com.google.gerrit.entities.LabelFunction;
@@ -39,6 +40,7 @@
         .setAllowPostSubmit(proto.getAllowPostSubmit())
         .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
+        .setCopyCondition(Strings.emptyToNull(proto.getCopyCondition()))
         .setCopyAnyScore(proto.getCopyAnyScore())
         .setCopyMinScore(proto.getCopyMinScore())
         .setCopyMaxScore(proto.getCopyMaxScore())
@@ -67,6 +69,7 @@
                 .map(LabelValueSerializer::serialize)
                 .collect(toImmutableList()))
         .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
+        .setCopyCondition(autoValue.getCopyCondition().orElse(""))
         .setCopyAnyScore(autoValue.isCopyAnyScore())
         .setCopyMinScore(autoValue.isCopyMinScore())
         .setCopyMaxScore(autoValue.isCopyMaxScore())
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
index ad015d1..47a377f 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
@@ -28,7 +28,8 @@
         .setDescription(Optional.ofNullable(Strings.emptyToNull(proto.getDescription())))
         .setApplicabilityExpression(
             SubmitRequirementExpression.of(proto.getApplicabilityExpression()))
-        .setBlockingExpression(SubmitRequirementExpression.create(proto.getBlockingExpression()))
+        .setSubmittabilityExpression(
+            SubmitRequirementExpression.create(proto.getSubmittabilityExpression()))
         .setOverrideExpression(SubmitRequirementExpression.of(proto.getOverrideExpression()))
         .setAllowOverrideInChildProjects(proto.getAllowOverrideInChildProjects())
         .build();
@@ -40,10 +41,11 @@
         .setName(submitRequirement.name())
         .setDescription(submitRequirement.description().orElse(""))
         .setApplicabilityExpression(
-            submitRequirement.applicabilityExpression().orElse(emptyExpression).expression())
-        .setBlockingExpression(submitRequirement.blockingExpression().expression())
+            submitRequirement.applicabilityExpression().orElse(emptyExpression).expressionString())
+        .setSubmittabilityExpression(
+            submitRequirement.submittabilityExpression().expressionString())
         .setOverrideExpression(
-            submitRequirement.overrideExpression().orElse(emptyExpression).expression())
+            submitRequirement.overrideExpression().orElse(emptyExpression).expressionString())
         .setAllowOverrideInChildProjects(submitRequirement.allowOverrideInChildProjects())
         .build();
   }
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index 1d6fb3c..a333ce5 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -33,10 +33,10 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index c067fcb..6728ba2 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -45,10 +45,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 3729b59..27b71d6 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -30,12 +30,12 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 64472ea..3a12ad4 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -29,12 +29,12 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
diff --git a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
index a4f306b..3212c8d 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 647fdf0..d25dba0 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -29,11 +29,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 761b57d..d5b74a8 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -27,8 +27,8 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -94,7 +94,7 @@
         out,
         reviewerAccountId,
         cd,
-        approvalsUtil.byPatchSetUser(cd.notes(), psId, reviewerAccountId, null, null));
+        approvalsUtil.byPatchSetUser(cd.notes(), psId, reviewerAccountId));
   }
 
   public ReviewerInfo format(
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index 6f05072..f3c5193 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -265,7 +265,7 @@
     IdentifiedUser reviewerUser;
     boolean exactMatchFound = false;
     try {
-      reviewerUser = accountResolver.resolve(input.reviewer).asUniqueUser();
+      reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
       if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
           || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
         exactMatchFound = true;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 4794858..5a74c78 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -77,7 +77,6 @@
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.DynamicOptions;
@@ -101,6 +100,8 @@
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.approval.ApprovalCacheImpl;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
 import com.google.gerrit.server.avatar.AvatarProvider;
@@ -179,6 +180,7 @@
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+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;
@@ -237,6 +239,7 @@
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
     install(AccountCacheImpl.module());
+    install(ApprovalCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ChangeFinder.module());
@@ -270,6 +273,7 @@
     install(new SshAddressesModule());
     install(new FileInfoJsonModule(cfg));
     install(ThreadLocalRequestContext.module());
+    install(new ApprovalModule());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index caf495f..d3faac1 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -35,12 +35,12 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.data.AccountAttribute;
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 2dbafd2..0e0185a 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -29,12 +29,12 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 58df343..1da14f8 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -47,9 +47,9 @@
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -650,7 +650,7 @@
 
   private Iterable<PatchSetApproval> safeGetApprovals(ChangeNotes notes, PatchSet.Id psId) {
     try {
-      return approvalsUtil.byPatchSet(notes, psId, null, null);
+      return approvalsUtil.byPatchSet(notes, psId);
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Can't read approval records for %s", psId);
       return Collections.emptyList();
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 15bc603..ec2ed4f 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -100,7 +100,6 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
@@ -111,6 +110,7 @@
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index 5e951d3..cc203ad 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.query.change.ProjectPredicate;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -116,7 +115,7 @@
               .setLimit(limit)
               .query(
                   Predicate.and(
-                      new ProjectPredicate(projectName.get()),
+                      ChangePredicates.project(projectName),
                       ChangeStatusPredicate.open(),
                       ChangePredicates.owner(user)))) {
         PatchSet ps = cd.currentPatchSet();
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index cc908e4..b55e91b 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -43,11 +43,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.NotifyResolver;
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 7131d44..8aab647a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -107,6 +107,7 @@
 
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
 
+  // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
   public static final FieldDef<ChangeData, Integer> LEGACY_ID =
       integer("legacy_id").stored().build(cd -> cd.getId().get());
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index b8a5cd9..05c5c77 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
-import com.google.gerrit.server.query.change.LegacyChangeIdStrPredicate;
 
 /**
  * Index for Gerrit changes. This class is mainly used for typing the generic parent class that
@@ -33,6 +32,6 @@
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
     return getSchema().useLegacyNumericFields()
         ? ChangePredicates.id(id)
-        : new LegacyChangeIdStrPredicate(id);
+        : ChangePredicates.idStr(id);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 3a35d80..d805e39 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailMetadata;
 import com.google.gerrit.mail.TextParser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -51,6 +50,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 808d6a4..7fff232 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -18,13 +18,13 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritInstanceName;
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index ea76ab8..6af2345 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -78,8 +78,7 @@
     try {
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
-      for (PatchSetApproval ca :
-          args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id(), null, null)) {
+      for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id())) {
         LabelType lt = labelTypes.byLabel(ca.labelId());
         if (lt == null) {
           continue;
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 71cb8c9..76573f6 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.InsertedObject;
 import java.io.IOException;
@@ -127,4 +130,14 @@
     }
     return footerLines.get(key.getName().toLowerCase());
   }
+
+  public boolean isAttentionSetCommitOnly(boolean hasChangeMessage) {
+    return !hasChangeMessage
+        && footerLines
+            .keySet()
+            .equals(
+                Sets.newHashSet(
+                    FOOTER_PATCH_SET.getName().toLowerCase(),
+                    FOOTER_ATTENTION.getName().toLowerCase()));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index f4d6cd3..f12176b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -327,7 +327,6 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    updateCount++;
     Timestamp commitTimestamp = getCommitTimestamp(commit);
 
     createdOn = commitTimestamp;
@@ -370,7 +369,8 @@
       originalSubject = currSubject;
     }
 
-    parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
+    boolean hasChangeMessage =
+        parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
     if (topic == null) {
       topic = parseTopic(commit);
     }
@@ -435,6 +435,9 @@
 
     previousWorkInProgressFooter = null;
     parseWorkInProgress(commit);
+    if (countTowardsMaxUpdatesLimit(commit, hasChangeMessage)) {
+      updateCount++;
+    }
   }
 
   private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
@@ -720,7 +723,7 @@
     }
   }
 
-  private void parseChangeMessage(
+  private boolean parseChangeMessage(
       PatchSet.Id psId,
       Account.Id accountId,
       Account.Id realAccountId,
@@ -728,7 +731,7 @@
       Timestamp ts) {
     Optional<String> changeMsgString = getChangeMessageString(commit);
     if (!changeMsgString.isPresent()) {
-      return;
+      return false;
     }
 
     ChangeMessage changeMessage =
@@ -740,7 +743,7 @@
             changeMsgString.get(),
             realAccountId,
             tag);
-    allChangeMessages.add(changeMessage);
+    return allChangeMessages.add(changeMessage);
   }
 
   public static Optional<String> getChangeMessageString(ChangeNotesCommit commit) {
@@ -1197,4 +1200,9 @@
         .orElseThrow(
             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
   }
+
+  protected boolean countTowardsMaxUpdatesLimit(
+      ChangeNotesCommit commit, boolean hasChangeMessage) {
+    return !commit.isAttentionSetCommitOnly(hasChangeMessage);
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 9d23137..f56e933 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -579,10 +579,19 @@
 
   @Override
   protected boolean bypassMaxUpdates() {
-    // Allow abandoning or submitting a change even if it would exceed the max update count.
+    return isAbandonChange() || isAttentionSetChangeOnly();
+  }
+
+  private boolean isAbandonChange() {
     return status != null && status.isClosed();
   }
 
+  private boolean isAttentionSetChangeOnly() {
+    return (plannedAttentionSetUpdates != null
+        && plannedAttentionSetUpdates.size() > 0
+        && comments.isEmpty());
+  }
+
   @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws IOException {
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
index 351f31d..d02ec87 100644
--- a/java/com/google/gerrit/server/notedb/OpenRepo.java
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -178,9 +178,9 @@
             && !update.bypassMaxUpdates()) {
           throw new LimitExceededException(
               String.format(
-                  "Change %s may not exceed %d updates. It may still be abandoned or submitted. To"
-                      + " continue working on this change, recreate it with a new Change-Id, then"
-                      + " abandon this one.",
+                  "Change %s may not exceed %d updates. It may still be abandoned, submitted and you can add/remove"
+                      + " reviewers to/from the attention-set. To continue working on this change, recreate it with a new"
+                      + " Change-Id, then abandon this one.",
                   update.getId(), maxUpdates.get()));
         }
         curr = next;
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index eca2658..e450779 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -30,7 +30,7 @@
 
   /**
    * 1-based parent. Available if the old commit is the parent of the new commit and old commit is
-   * not the auto-merge.
+   * not the auto-merge. If set to 0, then comparison is for a root commit.
    */
   abstract Optional<Integer> parentNum();
 
@@ -48,6 +48,10 @@
     return new AutoValue_ComparisonType(Optional.empty(), true);
   }
 
+  public static ComparisonType againstRoot() {
+    return new AutoValue_ComparisonType(Optional.of(0), false);
+  }
+
   private static ComparisonType create(Optional<Integer> parent, boolean automerge) {
     return new AutoValue_ComparisonType(parent, automerge);
   }
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 6217239..dbbb7a6 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -341,6 +342,10 @@
 
     abstract ObjectId newCommit();
 
+    /**
+     * Base commit represents the old commit of the diff. For diffs against the root commit, this
+     * should be set to {@code EMPTY_TREE_ID}.
+     */
     abstract ObjectId baseCommit();
 
     abstract ComparisonType comparisonType();
@@ -386,6 +391,11 @@
       return result.build();
     }
     int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 0) {
+      result.baseCommit(EMPTY_TREE_ID);
+      result.comparisonType(ComparisonType.againstRoot());
+      return result.build();
+    }
     if (numParents == 1) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(1));
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 395312f..2133474 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
@@ -93,7 +94,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(4)
+            .version(5)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -189,6 +190,9 @@
     private ComparisonType getComparisonType(
         RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
         throws IOException {
+      if (oldCommitId.equals(EMPTY_TREE_ID)) {
+        return ComparisonType.againstRoot();
+      }
       RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
       RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
       for (int i = 0; i < newCommit.getParentCount(); i++) {
@@ -211,7 +215,7 @@
     }
 
     /**
-     * Creates a {@link FileDiffOutput} entry for the "Commit message" and "Merge list" file paths.
+     * Creates a {@link FileDiffOutput} entry for the "Commit message" or "Merge list" magic paths.
      */
     private FileDiffOutput createMagicPathEntry(
         FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
@@ -219,7 +223,10 @@
         RawTextComparator cmp = comparatorFor(key.whitespace());
         ComparisonType comparisonType =
             getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
-        RevCommit aCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+        RevCommit aCommit =
+            key.oldCommit().equals(EMPTY_TREE_ID)
+                ? null
+                : DiffUtil.getRevCommit(rw, key.oldCommit());
         RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
         return magicPath == MagicPath.COMMIT
             ? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
@@ -248,16 +255,19 @@
       }
     }
 
+    /**
+     * Creates a commit entry. {@code oldCommit} is null if the comparison is against a root commit.
+     */
     private FileDiffOutput createCommitEntry(
         ObjectReader reader,
-        RevCommit oldCommit,
+        @Nullable RevCommit oldCommit,
         RevCommit newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          comparisonType.isAgainstParentOrAutoMerge()
+          oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
               ? Text.EMPTY
               : Text.forCommit(reader, oldCommit);
       Text bText = Text.forCommit(reader, newCommit);
@@ -272,16 +282,20 @@
           diffAlgorithm);
     }
 
+    /**
+     * Creates a merge list entry. {@code oldCommit} is null if the comparison is against a root
+     * commit.
+     */
     private FileDiffOutput createMergeListEntry(
         ObjectReader reader,
-        RevCommit oldCommit,
+        @Nullable RevCommit oldCommit,
         RevCommit newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          comparisonType.isAgainstParentOrAutoMerge()
+          oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
               ? Text.EMPTY
               : Text.forMergeList(comparisonType, reader, oldCommit);
       Text bText = Text.forMergeList(comparisonType, reader, newCommit);
@@ -297,7 +311,7 @@
     }
 
     private static FileDiffOutput createMagicFileDiffOutput(
-        ObjectId oldCommit,
+        @Nullable ObjectId oldCommit,
         ObjectId newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
@@ -317,7 +331,7 @@
       FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
       Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
       return FileDiffOutput.builder()
-          .oldCommitId(oldCommit)
+          .oldCommitId(oldCommit == null ? EMPTY_TREE_ID : oldCommit)
           .newCommitId(newCommit)
           .comparisonType(comparisonType)
           .oldPath(FileHeaderUtil.getOldPath(fileHeader))
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
index a478fcf..7ac9343 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Cache key for the {@link FileDiffCache}. */
@@ -35,7 +36,10 @@
   /** A specific git project / repository. */
   public abstract Project.NameKey project();
 
-  /** The 20 bytes SHA-1 commit ID of the old commit used in the diff. */
+  /**
+   * The 20 bytes SHA-1 commit ID of the old commit used in the diff. If set to {@link
+   * Constants#EMPTY_TREE_ID}, the diff is performed against the root commit.
+   */
   public abstract ObjectId oldCommit();
 
   /** The 20 bytes SHA-1 commit ID of the new commit used in the diff. */
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 730162f..63c9d22 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -32,6 +32,7 @@
     label.defaultValue = labelType.getDefaultValue();
     label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
     label.canOverride = toBoolean(labelType.isCanOverride());
+    label.copyCondition = labelType.getCopyCondition().orElse(null);
     label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
     label.copyMinScore = toBoolean(labelType.isCopyMinScore());
     label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 1b11ba2..e69967c 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -81,7 +81,13 @@
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 
-/** Cache of project information, including access rights. */
+/**
+ * Cache of project information, including access rights.
+ *
+ * <p>The data of a project is the project's project.config in refs/meta/config parsed out as an
+ * immutable value. It's keyed purely by the refs/meta/config SHA-1. We also cache the same value
+ * keyed by name. The latter mapping can become outdated, so data must be evicted explicitly.
+ */
 @Singleton
 public class ProjectCacheImpl implements ProjectCache {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 5ac5ac7..9f898d9 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -112,6 +112,7 @@
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+  public static final String KEY_COPY_CONDITION = "copyCondition";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
   public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
       "copyAllScoresIfListOfFilesDidNotChange";
@@ -129,7 +130,7 @@
   public static final String KEY_SR_NAME = "name";
   public static final String KEY_SR_DESCRIPTION = "description";
   public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicabilityExpression";
-  public static final String KEY_SR_BLOCKING_EXPRESSION = "blockingExpression";
+  public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittabilityExpression";
   public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideExpression";
   public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
 
@@ -984,7 +985,7 @@
       lowerNames.put(lower, name);
       String description = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION);
       String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
-      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_BLOCKING_EXPRESSION);
+      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
       String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
       boolean canInherit =
           rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
@@ -994,7 +995,7 @@
             ValidationError.create(
                 PROJECT_CONFIG,
                 (String.format(
-                    "Submit requirement \"%s\" does not define a blocking expression."
+                    "Submit requirement \"%s\" does not define a submittability expression."
                         + " Skipping this requirement.",
                     name))));
         continue;
@@ -1008,7 +1009,7 @@
               .setName(name)
               .setDescription(Optional.ofNullable(description))
               .setApplicabilityExpression(SubmitRequirementExpression.of(appExpr))
-              .setBlockingExpression(SubmitRequirementExpression.create(blockExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(blockExpr))
               .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
               .setAllowOverrideInChildProjects(canInherit)
               .build();
@@ -1075,6 +1076,7 @@
                     KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet()))));
       }
       label.setFunction(function.orElse(null));
+      label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
 
       if (!values.isEmpty()) {
         short dv = (short) rc.getInt(LABEL, name, KEY_DEFAULT_VALUE, 0);
@@ -1664,6 +1666,11 @@
         values.add(value.format().trim());
       }
       rc.setStringList(LABEL, name, KEY_VALUE, values);
+      if (label.getCopyCondition().isPresent()) {
+        rc.setString(LABEL, name, KEY_COPY_CONDITION, label.getCopyCondition().get());
+      } else {
+        rc.unset(LABEL, name, KEY_COPY_CONDITION);
+      }
 
       List<String> refPatterns = label.getRefPatterns();
       if (refPatterns != null && !refPatterns.isEmpty()) {
@@ -1694,19 +1701,19 @@
               SUBMIT_REQUIREMENT,
               name,
               KEY_SR_APPLICABILITY_EXPRESSION,
-              sr.applicabilityExpression().get().expression());
+              sr.applicabilityExpression().get().expressionString());
         }
         rc.setString(
             SUBMIT_REQUIREMENT,
             name,
-            KEY_SR_BLOCKING_EXPRESSION,
-            sr.blockingExpression().expression());
+            KEY_SR_SUBMITTABILITY_EXPRESSION,
+            sr.submittabilityExpression().expressionString());
         if (sr.overrideExpression().isPresent()) {
           rc.setString(
               SUBMIT_REQUIREMENT,
               name,
               KEY_SR_OVERRIDE_EXPRESSION,
-              sr.overrideExpression().get().expression());
+              sr.overrideExpression().get().expressionString());
         }
         rc.setBoolean(
             SUBMIT_REQUIREMENT,
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 249eb35..03d38b3 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -63,8 +63,8 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Cached information on a project. Must not contain any data derived from parents other than it's
- * immediate parent's {@link com.google.gerrit.entities.Project.NameKey}.
+ * State of a project, aggregated from the project and its parents. This is obtained from the {@link
+ * ProjectCache}. It should not be persisted across requests
  */
 public class ProjectState {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 812d98d..fca1b36 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -48,10 +48,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeIdPredicate;
-import com.google.gerrit.server.query.change.CommitPredicate;
-import com.google.gerrit.server.query.change.ProjectPredicate;
-import com.google.gerrit.server.query.change.RefPredicate;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -151,7 +148,7 @@
 
       // Base predicate which is fixed for every change query.
       Predicate<ChangeData> basePredicate =
-          and(new ProjectPredicate(projectName.get()), new RefPredicate(branch), open());
+          and(ChangePredicates.project(projectName), ChangePredicates.ref(branch), open());
 
       int maxLeafPredicates = indexConfig.maxTerms() - basePredicate.getLeafCount();
 
@@ -231,11 +228,11 @@
               }
 
               // Find changes that have a matching Change-Id.
-              predicates.add(new ChangeIdPredicate(changeId));
+              predicates.add(ChangePredicates.idPrefix(changeId));
             });
 
         // Find changes that have a matching commit.
-        predicates.add(new CommitPredicate(commit.name()));
+        predicates.add(ChangePredicates.commitPrefix(commit.name()));
       }
 
       if (!predicates.isEmpty()) {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
new file mode 100644
index 0000000..1d154dd
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2021 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+/** Evaluates submit requirements for different change data. */
+@Singleton
+public class SubmitRequirementsEvaluator {
+  private final Provider<ChangeQueryBuilder> changeQueryBuilderProvider;
+
+  @Inject
+  private SubmitRequirementsEvaluator(Provider<ChangeQueryBuilder> changeQueryBuilderProvider) {
+    this.changeQueryBuilderProvider = changeQueryBuilderProvider;
+  }
+
+  /**
+   * Validate a {@link SubmitRequirementExpression}. Callers who wish to validate submit
+   * requirements upon creation or update should use this method.
+   *
+   * @param expression entity containing the expression string.
+   * @throws QueryParseException the expression string contains invalid syntax and can't be parsed.
+   */
+  public void validateExpression(SubmitRequirementExpression expression)
+      throws QueryParseException {
+    changeQueryBuilderProvider.get().parse(expression.expressionString());
+  }
+
+  /** Evaluate a {@link SubmitRequirement} on a given {@link ChangeData}. */
+  public SubmitRequirementResult evaluate(SubmitRequirement sr, ChangeData cd) {
+    SubmitRequirementExpressionResult blockingResult =
+        evaluateExpression(sr.submittabilityExpression(), cd);
+
+    Optional<SubmitRequirementExpressionResult> applicabilityResult =
+        sr.applicabilityExpression().isPresent()
+            ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
+            : Optional.empty();
+
+    Optional<SubmitRequirementExpressionResult> overrideResult =
+        sr.overrideExpression().isPresent()
+            ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
+            : Optional.empty();
+
+    return SubmitRequirementResult.builder()
+        .submittabilityExpressionResult(blockingResult)
+        .applicabilityExpressionResult(applicabilityResult)
+        .overrideExpressionResult(overrideResult)
+        .build();
+  }
+
+  /** Evaluate a {@link SubmitRequirementExpression} using change data. */
+  public SubmitRequirementExpressionResult evaluateExpression(
+      SubmitRequirementExpression expression, ChangeData changeData) {
+    try {
+      Predicate<ChangeData> predicate =
+          changeQueryBuilderProvider.get().parse(expression.expressionString());
+      PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
+      return SubmitRequirementExpressionResult.create(predicateResult);
+    } catch (QueryParseException e) {
+      return SubmitRequirementExpressionResult.error(e.getMessage());
+    }
+  }
+
+  /** Evaluate the predicate recursively using change data. */
+  private PredicateResult evaluatePredicateTree(
+      Predicate<ChangeData> predicate, ChangeData changeData) {
+    PredicateResult.Builder predicateResult =
+        PredicateResult.builder()
+            .predicateString(predicate.toString())
+            .status(predicate.asMatchable().match(changeData));
+    predicate
+        .getChildren()
+        .forEach(
+            c -> predicateResult.addChildPredicateResult(evaluatePredicateTree(c, changeData)));
+    return predicateResult.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index e4da946..8f94089 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.server.account.AccountState;
@@ -132,8 +131,7 @@
   }
 
   /** Predicate that is mapped to a field in the account index. */
-  static class AccountPredicate extends IndexPredicate<AccountState>
-      implements Matchable<AccountState> {
+  static class AccountPredicate extends IndexPredicate<AccountState> {
     AccountPredicate(FieldDef<AccountState, ?> def, String value) {
       super(def, value);
     }
@@ -141,16 +139,6 @@
     AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
       super(def, name, value);
     }
-
-    @Override
-    public boolean match(AccountState object) {
-      return true;
-    }
-
-    @Override
-    public int getCost() {
-      return 1;
-    }
   }
 
   private AccountPredicates() {}
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
new file mode 100644
index 0000000..4c2c7e8
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.notedb.ChangeNotes;
+
+/** Entity representing all required information to match predicates for copying approvals. */
+@AutoValue
+public abstract class ApprovalContext {
+  /** Approval on the source patch set to be copied. */
+  public abstract PatchSetApproval patchSetApproval();
+
+  /** Target change and patch set for the approval. */
+  public abstract PatchSet.Id target();
+
+  /** {@link ChangeNotes} of the change in question. */
+  public abstract ChangeNotes changeNotes();
+
+  /** {@link ChangeKind} of the delta between the origin and target patch set. */
+  public abstract ChangeKind changeKind();
+
+  public static ApprovalContext create(
+      ChangeNotes changeNotes, PatchSetApproval psa, PatchSet.Id id, ChangeKind changeKind) {
+    checkState(
+        psa.patchSetId().changeId().equals(id.changeId()),
+        "approval and target must be the same change. got: %s, %s",
+        psa.patchSetId(),
+        id);
+    checkState(
+        psa.patchSetId().get() + 1 == id.get(),
+        "approvals can only be copied to the next consecutive patch set. got: %s, %s",
+        psa.patchSetId(),
+        id);
+    return new AutoValue_ApprovalContext(psa, id, changeNotes, changeKind);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalModule.java b/java/com/google/gerrit/server/query/approval/ApprovalModule.java
new file mode 100644
index 0000000..ff4d5ad
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalModule.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+
+/** Module to bind logic related to approval copying. */
+public class ApprovalModule extends FactoryModule {
+
+  @Override
+  protected void configure() {
+    factory(MagicValuePredicate.Factory.class);
+    factory(UserInPredicate.Factory.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java b/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
new file mode 100644
index 0000000..a6f8153
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.Predicate;
+
+public abstract class ApprovalPredicate extends Predicate<ApprovalContext>
+    implements Matchable<ApprovalContext> {
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
new file mode 100644
index 0000000..c3594f5
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.extensions.client.ChangeKind;
+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.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.inject.Inject;
+import java.util.Arrays;
+
+public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
+  private static final QueryBuilder.Definition<ApprovalContext, ApprovalQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(ApprovalQueryBuilder.class);
+
+  private final MagicValuePredicate.Factory magicValuePredicate;
+  private final UserInPredicate.Factory userInPredicate;
+  private final GroupResolver groupResolver;
+  private final GroupControl.Factory groupControl;
+  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
+
+  @Inject
+  protected ApprovalQueryBuilder(
+      MagicValuePredicate.Factory magicValuePredicate,
+      UserInPredicate.Factory userInPredicate,
+      GroupResolver groupResolver,
+      GroupControl.Factory groupControl,
+      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
+    super(mydef, null);
+    this.magicValuePredicate = magicValuePredicate;
+    this.userInPredicate = userInPredicate;
+    this.groupResolver = groupResolver;
+    this.groupControl = groupControl;
+    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> changeKind(String term) throws QueryParseException {
+    return new ChangeKindPredicate(toEnumValue(ChangeKind.class, term));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> is(String term) throws QueryParseException {
+    return magicValuePredicate.create(toEnumValue(MagicValuePredicate.MagicValue.class, term));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> approverin(String group) throws QueryParseException {
+    return userInPredicate.create(UserInPredicate.Field.APPROVER, parseGroupOrThrow(group));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> uploaderin(String group) throws QueryParseException {
+    return userInPredicate.create(UserInPredicate.Field.UPLOADER, parseGroupOrThrow(group));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> has(String value) throws QueryParseException {
+    if (value.equals("unchanged-files")) {
+      return listOfFilesUnchangedPredicate;
+    }
+    throw error(
+        String.format(
+            "'%s' is not a supported argument for has. only 'unchanged-files' is supported",
+            value));
+  }
+
+  private static <T extends Enum<T>> T toEnumValue(Class<T> clazz, String term)
+      throws QueryParseException {
+    try {
+      return Enum.valueOf(clazz, term.toUpperCase().replace('-', '_'));
+    } catch (
+        @SuppressWarnings("UnusedException")
+        IllegalArgumentException unused) {
+      throw new QueryParseException(
+          String.format(
+              "%s is not a valid term. valid options: %s",
+              term, Arrays.asList(clazz.getEnumConstants())));
+    }
+  }
+
+  private AccountGroup.UUID parseGroupOrThrow(String maybeUUID) throws QueryParseException {
+    GroupDescription.Basic g = groupResolver.parseId(maybeUUID);
+    if (g == null || !groupControl.controlFor(g).isVisible()) {
+      throw error("Group " + maybeUUID + " not found");
+    }
+    return g.getGroupUUID();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
new file mode 100644
index 0000000..78711fd
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
+import java.util.Collection;
+import java.util.Objects;
+
+/**
+ * Predicate that matches patch set approvals we want to copy if the diff between the old and new
+ * patch set is of a certain kind.
+ */
+public class ChangeKindPredicate extends ApprovalPredicate {
+  private final ChangeKind changeKind;
+
+  ChangeKindPredicate(ChangeKind changeKind) {
+    this.changeKind = changeKind;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    return ctx.changeKind().equals(changeKind);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ChangeKindPredicate(changeKind);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(changeKind);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof ChangeKindPredicate)) {
+      return false;
+    }
+    return ((ChangeKindPredicate) other).changeKind.equals(changeKind);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
new file mode 100644
index 0000000..30097d8
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+
+/** Predicate that matches when the new patch-set includes the same files as the old patch-set. */
+@Singleton
+public class ListOfFilesUnchangedPredicate extends ApprovalPredicate {
+  private final PatchListCache patchListCache;
+
+  @Inject
+  public ListOfFilesUnchangedPredicate(PatchListCache patchListCache) {
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    PatchSet currentPatchset = ctx.changeNotes().getCurrentPatchSet();
+    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
+        ctx.changeNotes().getPatchSets().lowerEntry(currentPatchset.id());
+    PatchListKey key =
+        PatchListKey.againstCommit(
+            priorPatchSet.getValue().commitId(),
+            currentPatchset.commitId(),
+            DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    try {
+      return match(patchListCache.get(key, ctx.changeNotes().getProjectName()));
+    } catch (PatchListNotAvailableException ex) {
+      throw new StorageException(
+          "failed to compute difference in files, so won't copy"
+              + " votes on labels even if list of files is the same and "
+              + "copyAllIfListOfFilesDidNotChange",
+          ex);
+    }
+  }
+
+  public boolean match(PatchList patchList) {
+    return patchList.getPatches().stream()
+        .noneMatch(
+            p ->
+                p.getChangeType() == ChangeType.ADDED
+                    || p.getChangeType() == ChangeType.DELETED
+                    || p.getChangeType() == ChangeType.RENAMED);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ListOfFilesUnchangedPredicate(patchListCache);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(patchListCache);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof ListOfFilesUnchangedPredicate)) {
+      return false;
+    }
+    ListOfFilesUnchangedPredicate o = (ListOfFilesUnchangedPredicate) other;
+    return Objects.equals(o.patchListCache, patchListCache);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
new file mode 100644
index 0000000..2924e6e
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.Objects;
+
+/** Predicate that matches patch set approvals we want to copy based on the value. */
+public class MagicValuePredicate extends ApprovalPredicate {
+  enum MagicValue {
+    MIN,
+    MAX,
+    ANY
+  }
+
+  public interface Factory {
+    MagicValuePredicate create(MagicValue value);
+  }
+
+  private final MagicValue value;
+  private final ProjectCache projectCache;
+
+  @Inject
+  MagicValuePredicate(ProjectCache projectCache, @Assisted MagicValue value) {
+    this.projectCache = projectCache;
+    this.value = value;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    short pValue;
+    switch (value) {
+      case ANY:
+        return true;
+      case MIN:
+        pValue =
+            getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId())
+                .getMaxNegative();
+        break;
+      case MAX:
+        pValue =
+            getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId())
+                .getMaxPositive();
+        break;
+      default:
+        throw new IllegalArgumentException("unrecognized label value: " + value);
+    }
+    return pValue == ctx.patchSetApproval().value();
+  }
+
+  private LabelType getLabelType(Project.NameKey project, LabelId labelId) {
+    return projectCache
+        .get(project)
+        .orElseThrow(() -> new IllegalStateException(project + " absent"))
+        .getLabelTypes()
+        .byLabel(labelId);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new MagicValuePredicate(projectCache, value);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(value);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof MagicValuePredicate)) {
+      return false;
+    }
+    return ((MagicValuePredicate) other).value.equals(value);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
new file mode 100644
index 0000000..7e16fcb
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
@@ -0,0 +1,71 @@
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.Objects;
+
+/** Predicate that matches group memberships of users such as uploader or approver. */
+public class UserInPredicate extends ApprovalPredicate {
+  interface Factory {
+    UserInPredicate create(Field field, AccountGroup.UUID group);
+  }
+
+  enum Field {
+    UPLOADER,
+    APPROVER
+  }
+
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final Field field;
+  private final AccountGroup.UUID group;
+
+  @Inject
+  UserInPredicate(
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Assisted Field field,
+      @Assisted AccountGroup.UUID group) {
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.field = field;
+    this.group = group;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    Account.Id accountId;
+    if (field == Field.UPLOADER) {
+      PatchSet patchSet = ctx.changeNotes().getPatchSets().get(ctx.target());
+      accountId = patchSet.uploader();
+    } else if (field == Field.APPROVER) {
+      accountId = ctx.patchSetApproval().accountId();
+    } else {
+      throw new IllegalStateException("unknown field in group membership check: " + field);
+    }
+    return identifiedUserFactory.create(accountId).getEffectiveGroups().contains(group);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new UserInPredicate(identifiedUserFactory, field, group);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(field, group);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof UserInPredicate)) {
+      return false;
+    }
+    UserInPredicate o = (UserInPredicate) other;
+    return Objects.equals(o.field, field) && Objects.equals(o.group, group);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/java/com/google/gerrit/server/query/change/AuthorPredicate.java
deleted file mode 100644
index 6e362ad..0000000
--- a/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.AUTHOR;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_AUTHOR;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class AuthorPredicate extends ChangeIndexPredicate {
-  public AuthorPredicate(String value) {
-    super(AUTHOR, FIELD_AUTHOR, value.toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getAuthorParts(object).contains(getValue().toLowerCase());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 84c6de0..6f8b097 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -58,7 +58,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.index.RefState;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
@@ -68,6 +67,7 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.CommentThread;
 import com.google.gerrit.server.change.CommentThreads;
 import com.google.gerrit.server.change.MergeabilityCache;
@@ -586,8 +586,7 @@
       } else {
         try {
           currentApprovals =
-              ImmutableList.copyOf(
-                  approvalsUtil.byPatchSet(notes(), c.currentPatchSetId(), null, null));
+              ImmutableList.copyOf(approvalsUtil.byPatchSet(notes(), c.currentPatchSetId()));
         } catch (StorageException e) {
           if (e.getCause() instanceof NoSuchChangeException) {
             currentApprovals = Collections.emptyList();
@@ -1323,7 +1322,7 @@
       }
 
       draftsByUser = new HashMap<>();
-      for (Ref ref : commentsUtil.getDraftRefs(notes.getChangeId())) {
+      for (Ref ref : commentsUtil.getDraftRefs(notes().getChangeId())) {
         Account.Id account = Account.Id.fromRefSuffix(ref.getName());
         if (account != null
             // Double-check that any drafts exist for this user after
diff --git a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
deleted file mode 100644
index f06d1f2..0000000
--- a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-/** Predicate over Change-Id strings (aka Change.Key). */
-public class ChangeIdPredicate extends ChangeIndexPredicate {
-  public ChangeIdPredicate(String id) {
-    super(ChangeField.ID, ChangeQueryBuilder.FIELD_CHANGE, id);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    Change change = cd.change();
-    if (change == null) {
-      return false;
-    }
-
-    String key = change.getKey().get();
-    if (key.equals(getValue()) || key.startsWith(getValue())) {
-      return true;
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index 28bfb0b..ccd4109 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,17 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.primitives.Ints;
 import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
-import java.util.Objects;
 
 /** Predicate that is mapped to a field in the change index. */
-public class ChangeIndexPredicate extends IndexPredicate<ChangeData>
-    implements Matchable<ChangeData> {
+public class ChangeIndexPredicate extends IndexPredicate<ChangeData> {
   /**
    * Returns an index predicate that matches no changes in the index.
    *
@@ -44,32 +39,4 @@
   protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (getField().isRepeatable()) {
-      Iterable<Object> values = (Iterable<Object>) getField().get(cd);
-      for (Object v : values) {
-        if (matchesSingleObject(v)) {
-          return true;
-        }
-      }
-      return false;
-    } else {
-      return matchesSingleObject(getField().get(cd));
-    }
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-
-  private boolean matchesSingleObject(Object fieldValueFromObject) {
-    String fieldTypeName = getField().getType().getName();
-    if (fieldTypeName.equals(FieldType.INTEGER.getName())) {
-      return Objects.equals(fieldValueFromObject, Ints.tryParse(value));
-    }
-    throw new UnsupportedOperationException("match function must be provided in subclass");
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 568916d..617002d 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -14,14 +14,19 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.base.CharMatcher;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.change.HashtagsUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Locale;
 
 /** Predicates that match against {@link ChangeData}. */
 public class ChangePredicates {
@@ -97,6 +102,12 @@
         ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
   }
 
+  /** Returns a predicate that matches the change with the provided {@link Change.Id}. */
+  public static Predicate<ChangeData> idStr(Change.Id id) {
+    return new ChangeIndexPredicate(
+        ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
+  }
+
   /** Returns a predicate that matches changes owned by the provided {@link Account.Id}. */
   public static Predicate<ChangeData> owner(Account.Id id) {
     return new ChangeIndexPredicate(ChangeField.OWNER, id.toString());
@@ -119,4 +130,152 @@
         cherryPickOf(psId.changeId()),
         new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_PATCHSET, String.valueOf(psId.get())));
   }
+
+  /** Returns a predicate that matches changes in the provided {@link Project.NameKey}. */
+  public static Predicate<ChangeData> project(Project.NameKey id) {
+    return new ChangeIndexPredicate(ChangeField.PROJECT, id.get());
+  }
+
+  /** Returns a predicate that matches changes targeted at the provided {@code refName}. */
+  public static Predicate<ChangeData> ref(String refName) {
+    return new ChangeIndexPredicate(ChangeField.REF, refName);
+  }
+
+  /** Returns a predicate that matches changes in the provided {@code topic}. */
+  public static Predicate<ChangeData> exactTopic(String topic) {
+    return new ChangeIndexPredicate(ChangeField.EXACT_TOPIC, topic);
+  }
+
+  /** Returns a predicate that matches changes in the provided {@code topic}. */
+  public static Predicate<ChangeData> fuzzyTopic(String topic) {
+    return new ChangeIndexPredicate(ChangeField.FUZZY_TOPIC, topic);
+  }
+
+  /** Returns a predicate that matches changes submitted in the provided {@code changeSet}. */
+  public static Predicate<ChangeData> submissionId(String changeSet) {
+    return new ChangeIndexPredicate(ChangeField.SUBMISSIONID, changeSet);
+  }
+
+  /** Returns a predicate that matches changes that modified the provided {@code path}. */
+  public static Predicate<ChangeData> path(String path) {
+    return new ChangeIndexPredicate(ChangeField.PATH, path);
+  }
+
+  /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
+  public static Predicate<ChangeData> hashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+  }
+
+  /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
+  public static Predicate<ChangeData> fuzzyHashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+  }
+
+  /** Returns a predicate that matches changes that modified the provided {@code file}. */
+  public static Predicate<ChangeData> file(ChangeQueryBuilder.Arguments args, String file) {
+    Predicate<ChangeData> eqPath = path(file);
+    if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
+      return eqPath;
+    }
+    return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART, file));
+  }
+
+  /**
+   * Returns a predicate that matches changes with the provided {@code footer} in their commit
+   * message.
+   */
+  public static Predicate<ChangeData> footer(String footer) {
+    int indexEquals = footer.indexOf('=');
+    int indexColon = footer.indexOf(':');
+
+    // footer key cannot contain '='
+    if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
+      footer = footer.substring(0, indexEquals) + ": " + footer.substring(indexEquals + 1);
+    }
+    return new ChangeIndexPredicate(ChangeField.FOOTER, footer.toLowerCase(Locale.US));
+  }
+
+  /**
+   * Returns a predicate that matches changes that modified files in the provided {@code directory}.
+   */
+  public static Predicate<ChangeData> directory(String directory) {
+    return new ChangeIndexPredicate(
+        ChangeField.DIRECTORY, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
+  }
+
+  /** Returns a predicate that matches changes with the provided {@code trackingId}. */
+  public static Predicate<ChangeData> trackingId(String trackingId) {
+    return new ChangeIndexPredicate(ChangeField.TR, trackingId);
+  }
+
+  /** Returns a predicate that matches changes authored by the provided {@code exactAuthor}. */
+  public static Predicate<ChangeData> exactAuthor(String exactAuthor) {
+    return new ChangeIndexPredicate(ChangeField.EXACT_AUTHOR, exactAuthor.toLowerCase(Locale.US));
+  }
+
+  /** Returns a predicate that matches changes authored by the provided {@code author}. */
+  public static Predicate<ChangeData> author(String author) {
+    return new ChangeIndexPredicate(ChangeField.AUTHOR, author);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the patch set was committed by {@code
+   * exactCommitter}.
+   */
+  public static Predicate<ChangeData> exactCommitter(String exactCommitter) {
+    return new ChangeIndexPredicate(
+        ChangeField.EXACT_COMMITTER, exactCommitter.toLowerCase(Locale.US));
+  }
+
+  /**
+   * Returns a predicate that matches changes where the patch set was committed by {@code
+   * committer}.
+   */
+  public static Predicate<ChangeData> committer(String comitter) {
+    return new ChangeIndexPredicate(ChangeField.COMMITTER, comitter.toLowerCase(Locale.US));
+  }
+
+  /** Returns a predicate that matches changes whose ID starts with the provided {@code id}. */
+  public static Predicate<ChangeData> idPrefix(String id) {
+    return new ChangeIndexPredicate(ChangeField.ID, id);
+  }
+
+  /**
+   * Returns a predicate that matches changes in a project that has the provided {@code prefix} in
+   * its name.
+   */
+  public static Predicate<ChangeData> projectPrefix(String prefix) {
+    return new ChangeIndexPredicate(ChangeField.PROJECTS, prefix);
+  }
+
+  /**
+   * Returns a predicate that matches changes where a patch set has the provided {@code commitId}
+   * either as prefix or as full {@link org.eclipse.jgit.lib.ObjectId}.
+   */
+  public static Predicate<ChangeData> commitPrefix(String commitId) {
+    if (commitId.length() == ObjectIds.STR_LEN) {
+      return new ChangeIndexPredicate(ChangeField.EXACT_COMMIT, commitId);
+    }
+    return new ChangeIndexPredicate(ChangeField.COMMIT, commitId);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@code message} appears in the
+   * commit message. Uses full-text search semantics.
+   */
+  public static Predicate<ChangeData> message(String message) {
+    return new ChangeIndexPredicate(ChangeField.COMMIT_MESSAGE, message);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@code comment} appears in any
+   * comment on any patch set of the change. Uses full-text search semantics.
+   */
+  public static Predicate<ChangeData> comment(String comment) {
+    return new ChangeIndexPredicate(ChangeField.COMMENT, comment);
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 7ebaec7..94b5442 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.NotSignedInException;
@@ -527,17 +528,17 @@
       return Predicate.and(
           project(triplet.get().project().get()),
           branch(triplet.get().branch().branch()),
-          new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
+          ChangePredicates.idPrefix(parseChangeId(triplet.get().id().get())));
     }
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
         return args.getSchema().useLegacyNumericFields()
             ? ChangePredicates.id(Change.id(id))
-            : new LegacyChangeIdStrPredicate(Change.id(id));
+            : ChangePredicates.idStr(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
-      return new ChangeIdPredicate(parseChangeId(query));
+      return ChangePredicates.idPrefix(parseChangeId(query));
     }
 
     throw new QueryParseException("Invalid change format");
@@ -545,7 +546,7 @@
 
   @Operator
   public Predicate<ChangeData> comment(String value) {
-    return new CommentPredicate(args.index, value);
+    return ChangePredicates.comment(value);
   }
 
   @Operator
@@ -704,7 +705,7 @@
 
   @Operator
   public Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
+    return ChangePredicates.commitPrefix(id);
   }
 
   @Operator
@@ -727,12 +728,12 @@
     if (name.startsWith("^")) {
       return new RegexProjectPredicate(name);
     }
-    return new ProjectPredicate(name);
+    return ChangePredicates.project(Project.nameKey(name));
   }
 
   @Operator
   public Predicate<ChangeData> projects(String name) {
-    return new ProjectPrefixPredicate(name);
+    return ChangePredicates.projectPrefix(name);
   }
 
   @Operator
@@ -790,7 +791,7 @@
 
   @Operator
   public Predicate<ChangeData> hashtag(String hashtag) {
-    return new ExactHashtagPredicate(hashtag);
+    return ChangePredicates.hashtag(hashtag);
   }
 
   @Operator
@@ -799,19 +800,19 @@
       return new RegexHashtagPredicate(hashtag);
     }
     if (hashtag.isEmpty()) {
-      return new ExactHashtagPredicate(hashtag);
+      return ChangePredicates.hashtag(hashtag);
     }
 
     if (!args.index.getSchema().hasField(ChangeField.FUZZY_HASHTAG)) {
       throw new QueryParseException(
           "'inhashtag' operator is not supported by change index version");
     }
-    return new FuzzyHashtagPredicate(hashtag, args.index);
+    return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
   @Operator
   public Predicate<ChangeData> topic(String name) {
-    return new ExactTopicPredicate(name);
+    return ChangePredicates.exactTopic(name);
   }
 
   @Operator
@@ -820,9 +821,9 @@
       return new RegexTopicPredicate(name);
     }
     if (name.isEmpty()) {
-      return new ExactTopicPredicate(name);
+      return ChangePredicates.exactTopic(name);
     }
-    return new FuzzyTopicPredicate(name, args.index);
+    return ChangePredicates.fuzzyTopic(name);
   }
 
   @Operator
@@ -830,7 +831,7 @@
     if (ref.startsWith("^")) {
       return new RegexRefPredicate(ref);
     }
-    return new RefPredicate(ref);
+    return ChangePredicates.ref(ref);
   }
 
   @Operator
@@ -843,7 +844,7 @@
     if (file.startsWith("^")) {
       return new RegexPathPredicate(file);
     }
-    return EqualsFilePredicate.create(args, file);
+    return ChangePredicates.file(args, file);
   }
 
   @Operator
@@ -851,7 +852,7 @@
     if (path.startsWith("^")) {
       return new RegexPathPredicate(path);
     }
-    return new EqualsPathPredicate(FIELD_PATH, path);
+    return ChangePredicates.path(path);
   }
 
   @Operator
@@ -884,7 +885,7 @@
   @Operator
   public Predicate<ChangeData> footer(String footer) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.FOOTER)) {
-      return new FooterPredicate(footer);
+      return ChangePredicates.footer(footer);
     }
     throw new QueryParseException("'footer' operator is not supported by change index version");
   }
@@ -900,7 +901,7 @@
       if (directory.startsWith("^")) {
         return new RegexDirectoryPredicate(directory);
       }
-      return new DirectoryPredicate(directory);
+      return ChangePredicates.directory(directory);
     }
     throw new QueryParseException("'directory' operator is not supported by change index version");
   }
@@ -997,7 +998,7 @@
 
   @Operator
   public Predicate<ChangeData> message(String text) {
-    return new MessagePredicate(args.index, text);
+    return ChangePredicates.message(text);
   }
 
   @Operator
@@ -1231,7 +1232,7 @@
 
   @Operator
   public Predicate<ChangeData> tr(String trackingId) {
-    return new TrackingIdPredicate(trackingId);
+    return ChangePredicates.trackingId(trackingId);
   }
 
   @Operator
@@ -1389,18 +1390,18 @@
   public Predicate<ChangeData> author(String who) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
       return getAuthorOrCommitterPredicate(
-          who.trim(), ExactAuthorPredicate::new, AuthorPredicate::new);
+          who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
     }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), AuthorPredicate::new);
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::author);
   }
 
   @Operator
   public Predicate<ChangeData> committer(String who) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
       return getAuthorOrCommitterPredicate(
-          who.trim(), ExactCommitterPredicate::new, CommitterPredicate::new);
+          who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
     }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), CommitterPredicate::new);
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::committer);
   }
 
   @Operator
@@ -1432,7 +1433,7 @@
   @Operator
   public Predicate<ChangeData> submissionId(String value) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.SUBMISSIONID)) {
-      return new SubmissionIdPredicate(value);
+      return ChangePredicates.submissionId(value);
     }
     throw new QueryParseException(
         "'submissionid' operator is not supported by change index version");
diff --git a/java/com/google/gerrit/server/query/change/CommentPredicate.java b/java/com/google/gerrit/server/query/change/CommentPredicate.java
deleted file mode 100644
index 0abe45d..0000000
--- a/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-public class CommentPredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public CommentPredicate(ChangeIndex index, String value) {
-    super(ChangeField.COMMENT, value);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    try {
-      Change.Id id = object.getId();
-      Predicate<ChangeData> p =
-          Predicate.and(
-              index.getSchema().useLegacyNumericFields()
-                  ? ChangePredicates.id(id)
-                  : new LegacyChangeIdStrPredicate(id),
-              this);
-      for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
-        if (cData.getId().equals(id)) {
-          return true;
-        }
-      }
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommitPredicate.java b/java/com/google/gerrit/server/query/change/CommitPredicate.java
deleted file mode 100644
index 2b3b345..0000000
--- a/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.git.ObjectIds.matchesAbbreviation;
-import static com.google.gerrit.server.index.change.ChangeField.COMMIT;
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMIT;
-
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.index.FieldDef;
-
-public class CommitPredicate extends ChangeIndexPredicate {
-  static FieldDef<ChangeData, ?> commitField(String id) {
-    if (id.length() == ObjectIds.STR_LEN) {
-      return EXACT_COMMIT;
-    }
-    return COMMIT;
-  }
-
-  public CommitPredicate(String id) {
-    super(commitField(id), id);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    String id = getValue().toLowerCase();
-    for (PatchSet p : object.patchSets()) {
-      if (equals(p, id)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  protected boolean equals(PatchSet p, String id) {
-    if (getField() == EXACT_COMMIT) {
-      return p.commitId().name().equals(id);
-    }
-    return matchesAbbreviation(p.commitId(), id);
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/java/com/google/gerrit/server/query/change/CommitterPredicate.java
deleted file mode 100644
index 65034a2..0000000
--- a/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.COMMITTER;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_COMMITTER;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class CommitterPredicate extends ChangeIndexPredicate {
-  public CommitterPredicate(String value) {
-    super(COMMITTER, FIELD_COMMITTER, value.toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getCommitterParts(object).contains(getValue().toLowerCase());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 80e3cb9..1ad9dba 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -81,17 +81,17 @@
 
     List<Predicate<ChangeData>> filePredicates = new ArrayList<>(files.size());
     for (String file : files) {
-      filePredicates.add(new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
+      filePredicates.add(ChangePredicates.path(file));
     }
 
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
-    and.add(new ProjectPredicate(c.getProject().get()));
-    and.add(new RefPredicate(c.getDest().branch()));
+    and.add(ChangePredicates.project(c.getProject()));
+    and.add(ChangePredicates.ref(c.getDest().branch()));
     and.add(
         Predicate.not(
             args.getSchema().useLegacyNumericFields()
                 ? ChangePredicates.id(c.getId())
-                : new LegacyChangeIdStrPredicate(c.getId())));
+                : ChangePredicates.idStr(c.getId())));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/DirectoryPredicate.java b/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
deleted file mode 100644
index 9249137..0000000
--- a/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class DirectoryPredicate extends ChangeIndexPredicate {
-  private static String clean(String directory) {
-    return CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US);
-  }
-
-  DirectoryPredicate(String value) {
-    super(ChangeField.DIRECTORY, clean(value));
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return ChangeField.getDirectories(cd).contains(value);
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
deleted file mode 100644
index 4e454fa..0000000
--- a/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-
-public class EqualsFilePredicate extends ChangeIndexPredicate {
-  public static Predicate<ChangeData> create(Arguments args, String value) {
-    Predicate<ChangeData> eqPath = new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
-    if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
-      return eqPath;
-    }
-    return Predicate.or(eqPath, new EqualsFilePredicate(value));
-  }
-
-  private EqualsFilePredicate(String value) {
-    super(ChangeField.FILE_PART, ChangeQueryBuilder.FIELD_FILE, value);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getFileParts(object).contains(value);
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
deleted file mode 100644
index 63975d0..0000000
--- a/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Collections;
-
-public class EqualsPathPredicate extends ChangeIndexPredicate {
-  public EqualsPathPredicate(String fieldName, String value) {
-    super(ChangeField.PATH, fieldName, value);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return Collections.binarySearch(object.currentFilePaths(), value) >= 0;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java b/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
deleted file mode 100644
index 3b3cb90..0000000
--- a/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_AUTHOR;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTAUTHOR;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class ExactAuthorPredicate extends ChangeIndexPredicate {
-  public ExactAuthorPredicate(String value) {
-    super(EXACT_AUTHOR, FIELD_EXACTAUTHOR, value.toLowerCase(Locale.US));
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getAuthorNameAndEmail(object).contains(getValue());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java b/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
deleted file mode 100644
index 390d4ab..0000000
--- a/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMITTER;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTCOMMITTER;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class ExactCommitterPredicate extends ChangeIndexPredicate {
-  public ExactCommitterPredicate(String value) {
-    super(EXACT_COMMITTER, FIELD_EXACTCOMMITTER, value.toLowerCase(Locale.US));
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getCommitterNameAndEmail(object).contains(getValue());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactHashtagPredicate.java b/java/com/google/gerrit/server/query/change/ExactHashtagPredicate.java
deleted file mode 100644
index 76ced90..0000000
--- a/java/com/google/gerrit/server/query/change/ExactHashtagPredicate.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.server.change.HashtagsUtil;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class ExactHashtagPredicate extends ChangeIndexPredicate {
-  public ExactHashtagPredicate(String hashtag) {
-    // Use toLowerCase without locale to match behavior in ChangeField.
-    // TODO(dborowitz): Change both.
-    super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (Strings.isNullOrEmpty(getValue())) {
-      return cd.hashtags().isEmpty();
-    }
-    for (String hashtag : cd.hashtags()) {
-      if (hashtag.equalsIgnoreCase(getValue())) {
-        return true;
-      }
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
deleted file mode 100644
index 5d8d2c0..0000000
--- a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
-
-import com.google.gerrit.entities.Change;
-
-public class ExactTopicPredicate extends ChangeIndexPredicate {
-  public ExactTopicPredicate(String topic) {
-    super(EXACT_TOPIC, topic);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    return getValue().equals(change.getTopic());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FooterPredicate.java b/java/com/google/gerrit/server/query/change/FooterPredicate.java
deleted file mode 100644
index 37bd6b1..0000000
--- a/java/com/google/gerrit/server/query/change/FooterPredicate.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class FooterPredicate extends ChangeIndexPredicate {
-  private static String clean(String value) {
-    int indexEquals = value.indexOf('=');
-    int indexColon = value.indexOf(':');
-
-    // footer key cannot contain '='
-    if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
-      value = value.substring(0, indexEquals) + ": " + value.substring(indexEquals + 1);
-    }
-    return value.toLowerCase(Locale.US);
-  }
-
-  FooterPredicate(String value) {
-    super(ChangeField.FOOTER, clean(value));
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return ChangeField.getFooters(cd).contains(value);
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java
deleted file mode 100644
index b4d6b5f..0000000
--- a/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.FUZZY_HASHTAG;
-
-import com.google.gerrit.server.index.change.ChangeIndex;
-
-public class FuzzyHashtagPredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public FuzzyHashtagPredicate(String hashtag, ChangeIndex index) {
-    super(FUZZY_HASHTAG, hashtag.toLowerCase());
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.hashtags().stream().anyMatch(ht -> ht.toLowerCase().contains(getValue()));
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
deleted file mode 100644
index 47652b8..0000000
--- a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.FUZZY_TOPIC;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-public class FuzzyTopicPredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public FuzzyTopicPredicate(String topic, ChangeIndex index) {
-    super(FUZZY_TOPIC, topic);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    Change change = cd.change();
-    if (change == null) {
-      return false;
-    }
-    String t = change.getTopic();
-    if (t == null) {
-      return false;
-    }
-    try {
-      Predicate<ChangeData> thisId =
-          index.getSchema().useLegacyNumericFields()
-              ? ChangePredicates.id(cd.getId())
-              : new LegacyChangeIdStrPredicate(cd.getId());
-      Iterable<ChangeData> results =
-          index.getSource(and(thisId, this), IndexedChangeQuery.oneResult()).read();
-      return !Iterables.isEmpty(results);
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index ed0f237..76ebd81 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -61,15 +61,15 @@
   }
 
   private static Predicate<ChangeData> ref(BranchNameKey branch) {
-    return new RefPredicate(branch.branch());
+    return ChangePredicates.ref(branch.branch());
   }
 
   private static Predicate<ChangeData> change(Change.Key key) {
-    return new ChangeIdPredicate(key.get());
+    return ChangePredicates.idPrefix(key.get());
   }
 
   private static Predicate<ChangeData> project(Project.NameKey project) {
-    return new ProjectPredicate(project.get());
+    return ChangePredicates.project(project);
   }
 
   private static Predicate<ChangeData> status(Change.Status status) {
@@ -77,7 +77,7 @@
   }
 
   private static Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
+    return ChangePredicates.commitPrefix(id);
   }
 
   private final ChangeData.Factory changeDataFactory;
@@ -100,7 +100,7 @@
         (id) ->
             schema().useLegacyNumericFields()
                 ? ChangePredicates.id(id)
-                : new LegacyChangeIdStrPredicate(id);
+                : ChangePredicates.idStr(id);
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -108,7 +108,7 @@
   }
 
   public List<ChangeData> byKeyPrefix(String prefix) {
-    return query(new ChangeIdPredicate(prefix));
+    return query(ChangePredicates.idPrefix(prefix));
   }
 
   public List<ChangeData> byLegacyChangeId(Change.Id id) {
@@ -225,7 +225,7 @@
   }
 
   public List<ChangeData> byTopicOpen(String topic) {
-    return query(and(new ExactTopicPredicate(topic), open()));
+    return query(and(ChangePredicates.exactTopic(topic), open()));
   }
 
   public List<ChangeData> byCommit(ObjectId id) {
@@ -269,14 +269,17 @@
 
   private static Predicate<ChangeData> byBranchCommitPred(
       String project, String branch, String hash) {
-    return and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash));
+    return and(
+        ChangePredicates.project(Project.nameKey(project)),
+        ChangePredicates.ref(branch),
+        commit(hash));
   }
 
   public List<ChangeData> bySubmissionId(String cs) {
     if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
     }
-    return query(new SubmissionIdPredicate(cs));
+    return query(ChangePredicates.submissionId(cs));
   }
 
   private static Predicate<ChangeData> byProjectGroupsPredicate(
diff --git a/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java b/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java
deleted file mode 100644
index 60cfd8f..0000000
--- a/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-
-import com.google.gerrit.entities.Change;
-
-/** Predicate over change number (aka legacy ID or Change.Id). */
-public class LegacyChangeIdStrPredicate extends ChangeIndexPredicate {
-  protected final Change.Id id;
-
-  public LegacyChangeIdStrPredicate(Change.Id id) {
-    super(LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return id.equals(object.getId());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/MessagePredicate.java b/java/com/google/gerrit/server/query/change/MessagePredicate.java
deleted file mode 100644
index caf751e..0000000
--- a/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-/** Predicate to match changes that contains specified text in commit messages body. */
-public class MessagePredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public MessagePredicate(ChangeIndex index, String value) {
-    super(ChangeField.COMMIT_MESSAGE, value);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    try {
-      Predicate<ChangeData> p =
-          Predicate.and(
-              index.getSchema().useLegacyNumericFields()
-                  ? ChangePredicates.id(object.getId())
-                  : new LegacyChangeIdStrPredicate(object.getId()),
-              this);
-      for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
-        if (cData.getId().equals(object.getId())) {
-          return true;
-        }
-      }
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 2c82075..4a54c03 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -47,10 +47,10 @@
     }
 
     List<Predicate<ChangeData>> r = new ArrayList<>();
-    r.add(new ProjectPredicate(projectState.get().getName()));
+    r.add(ChangePredicates.project(projectState.get().getNameKey()));
     try {
       for (ProjectInfo p : childProjects.list(projectState.get().getNameKey())) {
-        r.add(new ProjectPredicate(p.name));
+        r.add(ChangePredicates.project(Project.nameKey(p.name)));
       }
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log("cannot check permissions to expand child projects");
diff --git a/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
deleted file mode 100644
index b76207c..0000000
--- a/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class ProjectPredicate extends ChangeIndexPredicate {
-  public ProjectPredicate(String id) {
-    super(ChangeField.PROJECT, id);
-  }
-
-  protected Project.NameKey getValueKey() {
-    return Project.nameKey(getValue());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-
-    Project.NameKey p = change.getDest().project();
-    return p.equals(getValueKey());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
deleted file mode 100644
index b89fffe..0000000
--- a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class ProjectPrefixPredicate extends ChangeIndexPredicate {
-  public ProjectPrefixPredicate(String prefix) {
-    super(ChangeField.PROJECTS, prefix);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change c = object.change();
-    return c != null && c.getDest().project().get().startsWith(getValue());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/RefPredicate.java b/java/com/google/gerrit/server/query/change/RefPredicate.java
deleted file mode 100644
index 8dced69..0000000
--- a/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class RefPredicate extends ChangeIndexPredicate {
-  public RefPredicate(String ref) {
-    super(ChangeField.REF, ref);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    return getValue().equals(change.getDest().branch());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
deleted file mode 100644
index b653eb5..0000000
--- a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class SubmissionIdPredicate extends ChangeIndexPredicate {
-  public SubmissionIdPredicate(String changeSet) {
-    super(ChangeField.SUBMISSIONID, changeSet);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    if (change.getSubmissionId() == null) {
-      return false;
-    }
-    return getValue().equals(change.getSubmissionId());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
deleted file mode 100644
index 4f58be2..0000000
--- a/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class TrackingIdPredicate extends ChangeIndexPredicate {
-  public TrackingIdPredicate(String trackingId) {
-    super(ChangeField.TR, trackingId);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.trackingFooters().containsValue(getValue());
-  }
-}
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 9d8171c..8a2dc8d 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.group.GroupField;
 import java.util.Locale;
@@ -45,7 +44,7 @@
   }
 
   public static Predicate<InternalGroup> name(String name) {
-    return new NameGroupPredicate(name);
+    return new GroupPredicate(GroupField.NAME, name);
   }
 
   public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
@@ -76,25 +75,5 @@
     }
   }
 
-  // TODO(hiesel): This is just a one-off to make index tests work. Remove in favor of a more
-  // generic solution.
-  // This is required because Gerrit needs to look up groups by name on every request.
-  static class NameGroupPredicate extends IndexPredicate<InternalGroup>
-      implements Matchable<InternalGroup> {
-    NameGroupPredicate(String value) {
-      super(GroupField.NAME, value);
-    }
-
-    @Override
-    public boolean match(InternalGroup group) {
-      return group.getName().equals(getValue());
-    }
-
-    @Override
-    public int getCost() {
-      return 1;
-    }
-  }
-
   private GroupPredicates() {}
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index dc1cd10..5375936 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -33,11 +33,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 8ae902d..35cadb7 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -33,12 +33,12 @@
 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.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 3d07d43..12dbf4e 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index b44f637..2df2d29 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 22fcbc7..50b7516 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -40,11 +40,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 8d413fa..6816361 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -81,7 +81,6 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -91,6 +90,7 @@
 import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
@@ -1418,11 +1418,7 @@
       return !del.isEmpty() || !ups.isEmpty();
     }
 
-    /**
-     * Approval is copied over if it doesn't exist in the approvals of the current patch-set
-     * according to change notes (which means it was computed in {@link
-     * com.google.gerrit.server.ApprovalInference})
-     */
+    /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
     private boolean isApprovalCopiedOver(
         PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
       return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index 17ee92e..dcf616c 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -26,11 +26,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 4723d70..0356cdd 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -27,11 +27,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.CommentThread;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index d80ab696..9e74eec 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.SuggestedReviewer;
 import com.google.gerrit.server.config.GerritServerConfig;
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index 4bfcf14..9da7c88 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index 2651ab5..97383cda 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
diff --git a/java/com/google/gerrit/server/restapi/change/Votes.java b/java/com/google/gerrit/server/restapi/change/Votes.java
index d899002..0f4b960 100644
--- a/java/com/google/gerrit/server/restapi/change/Votes.java
+++ b/java/com/google/gerrit/server/restapi/change/Votes.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.inject.Inject;
@@ -83,9 +83,7 @@
           approvalsUtil.byPatchSetUser(
               rsrc.getChangeResource().getNotes(),
               rsrc.getChange().currentPatchSetId(),
-              rsrc.getReviewerUser().getAccountId(),
-              null,
-              null);
+              rsrc.getReviewerUser().getAccountId());
       for (PatchSetApproval psa : byPatchSetUser) {
         votes.put(psa.label(), psa.value());
       }
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 033463c..ae7f540 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -34,8 +34,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.Reachable;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.CommitPredicate;
-import com.google.gerrit.server.query.change.ProjectPredicate;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -149,10 +148,10 @@
     // branches to check, by seeing if its parents were associated to changes.
     Predicate<ChangeData> pred =
         Predicate.and(
-            new ProjectPredicate(project.get()),
+            ChangePredicates.project(project),
             Predicate.or(
                 Arrays.stream(commit.getParents())
-                    .map(parent -> new CommitPredicate(parent.getId().getName()))
+                    .map(parent -> ChangePredicates.commitPrefix(parent.getId().getName()))
                     .collect(toImmutableList())));
     changes =
         retryHelper
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index eceab43..92038b0 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -30,7 +30,7 @@
 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.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 2ae1b05..d2f4161 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -51,6 +53,7 @@
   private final MetaDataUpdate.User updateFactory;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectCache projectCache;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
 
   @Inject
   public CreateLabel(
@@ -58,12 +61,14 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.User updateFactory,
       ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalQueryBuilder approvalQueryBuilder) {
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.projectConfigFactory = projectConfigFactory;
     this.projectCache = projectCache;
+    this.approvalQueryBuilder = approvalQueryBuilder;
   }
 
   @Override
@@ -166,6 +171,23 @@
       labelType.setCopyAnyScore(input.copyAnyScore);
     }
 
+    if (input.copyCondition != null) {
+      try {
+        approvalQueryBuilder.parse(input.copyCondition);
+      } catch (QueryParseException e) {
+        throw new BadRequestException(
+            "unable to parse copy condition. got: " + input.copyCondition + ". " + e.getMessage(),
+            e);
+      }
+      if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+        throw new BadRequestException("can't set and unset copyCondition in the same request");
+      }
+      labelType.setCopyCondition(Strings.emptyToNull(input.copyCondition));
+    }
+    if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+      labelType.setCopyCondition(null);
+    }
+
     if (input.copyMinScore != null) {
       labelType.setCopyMinScore(input.copyMinScore);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index b1bcb15..d69abef 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.project.LabelResource;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -45,6 +47,7 @@
   private final MetaDataUpdate.User updateFactory;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectCache projectCache;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
 
   @Inject
   public SetLabel(
@@ -52,12 +55,14 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.User updateFactory,
       ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalQueryBuilder approvalQueryBuilder) {
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.projectConfigFactory = projectConfigFactory;
     this.projectCache = projectCache;
+    this.approvalQueryBuilder = approvalQueryBuilder;
   }
 
   @Override
@@ -174,6 +179,26 @@
       dirty = true;
     }
 
+    input.copyCondition = Strings.emptyToNull(input.copyCondition);
+    if (input.copyCondition != null) {
+      try {
+        approvalQueryBuilder.parse(input.copyCondition);
+      } catch (QueryParseException e) {
+        throw new BadRequestException(
+            "unable to parse copy condition. got: " + input.copyCondition + ". " + e.getMessage(),
+            e);
+      }
+      labelTypeBuilder.setCopyCondition(input.copyCondition);
+      dirty = true;
+      if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+        throw new BadRequestException("can't set and unset copyCondition in the same request");
+      }
+    }
+    if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+      labelTypeBuilder.setCopyCondition(null);
+      dirty = true;
+    }
+
     if (input.copyAnyScore != null) {
       labelTypeBuilder.setCopyAnyScore(input.copyAnyScore);
       dirty = true;
diff --git a/java/com/google/gerrit/server/submit/CommitMergeStatus.java b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
index bf8b840..4638bfa 100644
--- a/java/com/google/gerrit/server/submit/CommitMergeStatus.java
+++ b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
@@ -77,7 +77,14 @@
   EMPTY_COMMIT(
       "Change could not be merged because the commit is empty.\n"
           + "\n"
-          + "Project policy requires all commits to contain modifications to at least one file.");
+          + "Project policy requires all commits to contain modifications to at least one file."),
+
+  FAST_FORWARD_INDEPENDENT_CHANGES(
+      "Change could not be merged because the submission has two independent changes "
+          + "with the same destination branch.\n"
+          + "\n"
+          + "Independent changes can't be submitted to the same destination branch with "
+          + "FAST_FORWARD_ONLY submit strategy");
 
   private final String description;
 
diff --git a/java/com/google/gerrit/server/submit/FastForwardOnly.java b/java/com/google/gerrit/server/submit/FastForwardOnly.java
index 176b063..8a30898 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOnly.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOnly.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.update.RepoContext;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 public class FastForwardOnly extends SubmitStrategy {
   FastForwardOnly(SubmitStrategy.Arguments args) {
@@ -28,6 +32,21 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+
+    Map<BranchNameKey, CodeReviewCommit> branchToCommit = new HashMap<>();
+    for (CodeReviewCommit codeReviewCommit : sorted) {
+      BranchNameKey branchNameKey = codeReviewCommit.change().getDest();
+      CodeReviewCommit otherCommitInBranch = branchToCommit.get(branchNameKey);
+      if (otherCommitInBranch == null) {
+        branchToCommit.put(branchNameKey, codeReviewCommit);
+      } else {
+        // we found another change with the same destination branch.
+        codeReviewCommit.setStatusCode(CommitMergeStatus.FAST_FORWARD_INDEPENDENT_CHANGES);
+        otherCommitInBranch.setStatusCode(CommitMergeStatus.FAST_FORWARD_INDEPENDENT_CHANGES);
+        return ImmutableList.of();
+      }
+    }
+
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 530c53f..6291e6c 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -26,12 +26,12 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.SetPrivateOp;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
index b533bebc..59c6b81 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
@@ -134,6 +134,7 @@
         case NOT_FAST_FORWARD:
         case EMPTY_COMMIT:
         case MISSING_DEPENDENCY:
+        case FAST_FORWARD_INDEPENDENT_CHANGES:
           // TODO(dborowitz): Reformat these messages to be more appropriate for
           // short problem descriptions.
           String message = s.getDescription();
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index a63c7dc..09470d4 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -32,9 +32,9 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 7130339..1d50e82 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -576,7 +576,7 @@
               "Account 'user' only matches inactive accounts. To use an inactive account, retry"
                   + " with one of the following exact account IDs:\n"
                   + id
-                  + ": User <user@example.com>");
+                  + ": User1 <user1@example.com>");
       assertThat(gApi.accounts().id(id).getActive()).isFalse();
 
       gApi.accounts().id(id).setActive(true);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 2abbe1a..529ce73 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -79,6 +79,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -212,6 +213,7 @@
 
 @NoHttpd
 @UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
 public class ChangeIT extends AbstractDaemonTest {
 
   @Inject private AccountOperations accountOperations;
@@ -439,25 +441,25 @@
         .newAccount()
         .username(name("user1"))
         .preferredEmail(email1)
-        .fullname("User 1")
+        .fullname("User1")
         .create();
     accountOperations
         .newAccount()
         .username(name("user2"))
         .preferredEmail(email2)
-        .fullname("User 2")
+        .fullname("User2")
         .create();
     accountOperations
         .newAccount()
         .username(name("user3"))
         .preferredEmail(email3)
-        .fullname("User 3")
+        .fullname("User3")
         .create();
     accountOperations
         .newAccount()
         .username(name("user4"))
         .preferredEmail(email4)
-        .fullname("User 4")
+        .fullname("User4")
         .create();
     ReviewInput in =
         ReviewInput.noScore()
@@ -644,7 +646,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void reviewWithWorkInProgressChangeOwner() throws Exception {
     PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
@@ -659,7 +661,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void reviewWithWithWorkInProgressAdmin() throws Exception {
     PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
@@ -997,7 +999,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteNewChangeAsNormalUser() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1022,7 +1024,7 @@
   @Test
   public void deleteNewChangeAsUserWithDeleteChangesPermissionForProjectOwners() throws Exception {
     GroupApi groupApi = gApi.groups().create(name("delete-change"));
-    groupApi.addMembers("user");
+    groupApi.addMembers("user1");
 
     Project.NameKey nameKey = Project.nameKey(name("delete-change"));
     ProjectInput in = new ProjectInput();
@@ -1151,7 +1153,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteAbandonedChangeAsNormalUser() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1166,7 +1168,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1192,7 +1194,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
     projectOperations
         .project(project)
@@ -1804,7 +1806,7 @@
   }
 
   @Test
-  public void addReviewerThatIsInactive() throws Exception {
+  public void addReviewerThatIsInactiveByUsername() throws Exception {
     PushOneCommit.Result result = createChange();
 
     String username = name("new-user");
@@ -1815,19 +1817,11 @@
     ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
     assertThat(r.input).isEqualTo(in.reviewer);
-    assertThat(r.error)
-        .isEqualTo(
-            "Account '"
-                + username
-                + "' only matches inactive accounts. To use an inactive account, retry with one of"
-                + " the following exact account IDs:\n"
-                + id
-                + ": Name of user not set ("
-                + id
-                + ")\n"
-                + username
-                + " does not identify a registered user or group");
-    assertThat(r.reviewers).isNull();
+    assertThat(r.error).isNull();
+    assertThat(r.reviewers).hasSize(1);
+    ReviewerInfo reviewer = r.reviewers.get(0);
+    assertThat(reviewer._accountId).isEqualTo(id.get());
+    assertThat(reviewer.username).isEqualTo(username);
   }
 
   @Test
@@ -1850,15 +1844,13 @@
   }
 
   @Test
-  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
+  public void addReviewerThatIsInactiveByEmail() throws Exception {
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
-
     PushOneCommit.Result result = createChange();
-
     String username = "user@domain.com";
-    accountOperations.newAccount().username(username).inactive().create();
+    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
     ReviewerInput in = new ReviewerInput();
     in.reviewer = username;
@@ -1867,9 +1859,10 @@
 
     assertThat(r.input).isEqualTo(username);
     assertThat(r.error).isNull();
-    // When adding by email, the reviewers field is also empty because we can't
-    // render a ReviewerInfo object for a non-account.
-    assertThat(r.reviewers).isNull();
+    assertThat(r.ccs).hasSize(1);
+    AccountInfo reviewer = r.ccs.get(0);
+    assertThat(reviewer._accountId).isEqualTo(id.get());
+    assertThat(reviewer.username).isEqualTo(username);
   }
 
   @Test
@@ -1962,7 +1955,7 @@
         .newAccount()
         .username(username1)
         .preferredEmail(email1)
-        .fullname("User 1")
+        .fullname("User1")
         .create();
     in.reviewer = email1;
     in.state = ReviewerState.CC;
@@ -2656,7 +2649,7 @@
         .isEqualTo(
             "Removed Code-Review+1 by " + ChangeMessagesUtil.getAccountTemplate(user.id()) + "\n");
     assertThat(gApi.changes().id(r.getChangeId()).message(message.id).get().message)
-        .isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+        .isEqualTo("Removed Code-Review+1 by User1 <user1@example.com>\n");
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
@@ -3886,7 +3879,7 @@
     for (com.google.gerrit.acceptance.TestAccount acc : ImmutableList.of(admin, user)) {
       requestScopeOperations.setApiUser(acc.id());
       String newMessage =
-          "modified commit by " + acc.username() + "\n\nChange-Id: " + r.getChangeId() + "\n";
+          "modified commit by " + acc.id() + "\n\nChange-Id: " + r.getChangeId() + "\n";
       gApi.changes().id(r.getChangeId()).setMessage(newMessage);
       RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
       assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 503ab11..53a9364 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -28,15 +28,14 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
-import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -45,26 +44,19 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
 import java.util.EnumSet;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -73,6 +65,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
+  @Inject private ChangeKindCreator changeKindCreator;
 
   @Inject
   @Named("change_kind")
@@ -135,11 +128,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 0, changeKind);
       assertVotes(c, user, 1, 0, changeKind);
@@ -147,6 +140,49 @@
   }
 
   @Test
+  public void stickyWhenCopyConditionIsTrue() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:ANY"));
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, 1, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyEvenWhenUserCantSeeUploaderInGroup() throws Exception {
+    // user can't see admin group
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyCondition("approverin:" + administratorsUUID));
+      u.save();
+    }
+
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+    amendChange(changeId);
+    vote(user, changeId, 1, -1); // Invalidate cache
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, 1, -1);
+  }
+
+  @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
@@ -157,11 +193,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, -1, 1);
       vote(user, changeId, -2, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, -2, 0, changeKind);
@@ -169,6 +205,30 @@
   }
 
   @Test
+  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, -2, 0, changeKind);
+    }
+  }
+
+  @Test
   public void stickyOnMaxScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
@@ -179,11 +239,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -205,12 +265,12 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, -1, 1);
       vote(user, changeId, -2, -1);
       vote(user2, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, -1, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -226,16 +286,16 @@
       u.save();
     }
 
-    String changeId = createChange(TRIVIAL_REBASE);
+    String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, NO_CHANGE);
     assertVotes(c, user, -2, 0, NO_CHANGE);
 
-    updateChange(changeId, TRIVIAL_REBASE);
+    changeKindCreator.updateChange(changeId, TRIVIAL_REBASE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
     assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
@@ -248,7 +308,8 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    String cherryPickChangeId = cherryPick(changeId, TRIVIAL_REBASE);
+    String cherryPickChangeId =
+        changeKindCreator.cherryPick(changeId, TRIVIAL_REBASE, testRepo, admin, project);
     c = detailedChange(cherryPickChangeId);
     assertVotes(c, admin, 2, 0);
     assertVotes(c, user, -2, 0);
@@ -259,7 +320,7 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    cherryPickChangeId = cherryPick(changeId, REWORK);
+    cherryPickChangeId = changeKindCreator.cherryPick(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(cherryPickChangeId);
     assertVotes(c, admin, 0, 0);
     assertVotes(c, user, 0, 0);
@@ -272,16 +333,16 @@
       u.save();
     }
 
-    String changeId = createChange(NO_CODE_CHANGE);
+    String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CHANGE);
     assertVotes(c, user, 0, -1, NO_CHANGE);
 
-    updateChange(changeId, NO_CODE_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
     assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
@@ -298,16 +359,16 @@
       u.save();
     }
 
-    String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
+    String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, NO_CHANGE);
     assertVotes(c, user, -2, 0, NO_CHANGE);
 
-    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    changeKindCreator.updateChange(changeId, MERGE_FIRST_PARENT_UPDATE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
     assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
@@ -322,11 +383,11 @@
       u.save();
     }
 
-    String changeId = createChangeForMergeCommit();
+    String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateSecondParent(changeId);
+    changeKindCreator.updateSecondParent(changeId, testRepo, admin);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 0, 0, null);
     assertVotes(c, user, 0, 0, null);
@@ -439,7 +500,7 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, -2, -1);
 
@@ -450,7 +511,7 @@
       assertVotes(c, admin, 0, 0, null);
       assertVotes(c, user, 0, 0, null);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -465,16 +526,16 @@
       u.save();
     }
 
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
     for (int i = 0; i < 5; i++) {
-      updateChange(changeId, NO_CODE_CHANGE);
+      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
     }
 
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
   }
@@ -491,11 +552,11 @@
       u.save();
     }
 
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
-    updateChange(changeId, NO_CODE_CHANGE);
-    updateChange(changeId, NO_CODE_CHANGE);
-    updateChange(changeId, NO_CODE_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
 
     Map<Integer, ObjectId> revisions = new HashMap<>();
     gApi.changes()
@@ -524,24 +585,24 @@
     }
 
     // Vote max score on PS1
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
     // Have someone else vote min score on PS2
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     vote(user, changeId, -2, 0);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
     assertVotes(c, user, -2, 0, REWORK);
 
     // No vote changes on PS3
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
     assertVotes(c, user, -2, 0, REWORK);
 
     // Both users revote on PS4
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     vote(admin, changeId, 1, 1);
     vote(user, changeId, 1, 1);
     c = detailedChange(changeId);
@@ -549,7 +610,7 @@
     assertVotes(c, user, 1, 1, REWORK);
 
     // New approvals shouldn't carry through to PS5
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 0, 0, REWORK);
     assertVotes(c, user, 0, 0, REWORK);
@@ -564,10 +625,10 @@
     }
 
     // Vote max score on PS1
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, label, 2);
     assertVotes(detailedChange(changeId), admin, label, 2, null);
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
 
     // Delete vote that was copied via sticky approval
@@ -622,209 +683,17 @@
     for (ChangeKind changeKind : changeKinds) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, +2, 1);
       vote(user, changeId, -2, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
     }
   }
 
-  private String createChange(ChangeKind kind) throws Exception {
-    switch (kind) {
-      case NO_CODE_CHANGE:
-      case REWORK:
-      case TRIVIAL_REBASE:
-      case NO_CHANGE:
-        return createChange().getChangeId();
-      case MERGE_FIRST_PARENT_UPDATE:
-        return createChangeForMergeCommit();
-      default:
-        throw new IllegalStateException("unexpected change kind: " + kind);
-    }
-  }
-
-  private void updateChange(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case NO_CODE_CHANGE:
-        noCodeChange(changeId);
-        return;
-      case REWORK:
-        rework(changeId);
-        return;
-      case TRIVIAL_REBASE:
-        trivialRebase(changeId);
-        return;
-      case MERGE_FIRST_PARENT_UPDATE:
-        updateFirstParent(changeId);
-        return;
-      case NO_CHANGE:
-        noChange(changeId);
-        return;
-      default:
-        assertWithMessage("unexpected change kind: " + changeKind).fail();
-    }
-  }
-
-  private void noCodeChange(String changeId) throws Exception {
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message("New subject " + System.nanoTime())
-        .author(admin.newIdent())
-        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
-  }
-
-  private void noChange(String changeId) throws Exception {
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
-
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message(commitMessage)
-        .author(admin.newIdent())
-        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
-  }
-
-  private void rework(String changeId) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "new content " + System.nanoTime(),
-            changeId);
-    push.to("refs/for/master").assertOkStatus();
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private void trivialRebase(String changeId) throws Exception {
-    requestScopeOperations.setApiUser(admin.id());
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            "Other Change",
-            "a" + System.nanoTime() + ".txt",
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
-    revision.review(in);
-    revision.submit();
-
-    gApi.changes().id(changeId).current().rebase();
-    assertThat(getChangeKind(changeId)).isEqualTo(TRIVIAL_REBASE);
-  }
-
-  private String createChangeForMergeCommit() throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1");
-
-    testRepo.reset(initial);
-    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2");
-
-    testRepo.reset(parent1.getCommit());
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo);
-    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-    return result.getChangeId();
-  }
-
-  private void updateFirstParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
-
-    testRepo.reset(parent1);
-    PushOneCommit.Result newParent1 = createChange("new parent 1", "p1-1.txt", "content 1-1");
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(MERGE_FIRST_PARENT_UPDATE);
-  }
-
-  private void updateSecondParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
-
-    testRepo.reset(parent2);
-    PushOneCommit.Result newParent2 = createChange("new parent 2", "p2-2.txt", "content 2-2");
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private String cherryPick(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case REWORK:
-      case TRIVIAL_REBASE:
-        break;
-      case NO_CODE_CHANGE:
-      case NO_CHANGE:
-      case MERGE_FIRST_PARENT_UPDATE:
-      default:
-        assertWithMessage("unexpected change kind: " + changeKind).fail();
-    }
-
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                testRepo,
-                PushOneCommit.SUBJECT,
-                "other.txt",
-                "new content " + System.nanoTime())
-            .to("refs/for/master");
-    r.assertOkStatus();
-    vote(admin, r.getChangeId(), 2, 1);
-    merge(r);
-
-    String subject =
-        TRIVIAL_REBASE.equals(changeKind)
-            ? PushOneCommit.SUBJECT
-            : "Reworked change " + System.nanoTime();
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
-    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
-    return c.changeId;
-  }
-
-  private ChangeKind getChangeKind(String changeId) throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId).get(CURRENT_REVISION);
-    return c.revisions.get(c.currentRevision).kind;
-  }
-
   private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 8b0f61e..81b9ba8 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -201,10 +201,10 @@
   public void addRemoveMember() throws Exception {
     AccountGroup.UUID group = groupOperations.newGroup().create();
 
-    gApi.groups().id(group.get()).addMembers("user");
+    gApi.groups().id(group.get()).addMembers("user1");
     assertMembers(group.get(), user);
 
-    gApi.groups().id(group.get()).removeMembers("user");
+    gApi.groups().id(group.get()).removeMembers("user1");
     ImmutableSet<Account.Id> members = groupOperations.group(group).get().members();
     assertThat(members).isEmpty();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 45a895a..b0de1c1 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -236,10 +236,10 @@
                 Permission.VIEW_PRIVATE_CHANGES,
                 403,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/*'",
-                    "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+                    "'user1' cannot perform 'viewPrivateChanges' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/master'")),
             // Test 2
@@ -248,7 +248,7 @@
                 normalProject.get(),
                 200,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/*'")),
             // Test 3
@@ -257,10 +257,10 @@
                 secretProject.get(),
                 403,
                 ImmutableList.of(
-                    "'user' cannot perform 'read' with force=false on project '"
+                    "'user1' cannot perform 'read' with force=false on project '"
                         + secretProject.get()
                         + "' for ref 'refs/heads/*' because this permission is blocked",
-                    "'user' cannot perform 'read' with force=false on project '"
+                    "'user1' cannot perform 'read' with force=false on project '"
                         + secretProject.get()
                         + "' for ref 'refs/meta/version' because this permission is blocked")),
             // Test 4
@@ -270,10 +270,10 @@
                 "refs/heads/secret/master",
                 403,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + secretRefProject.get()
                         + "' for ref 'refs/heads/*'",
-                    "'user' cannot perform 'read' with force=false on project '"
+                    "'user1' cannot perform 'read' with force=false on project '"
                         + secretRefProject.get()
                         + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
             // Test 5
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 9bdc420..1a55b82 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
@@ -25,6 +26,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toMap;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -35,7 +37,10 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -46,6 +51,8 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.webui.EditWebLink;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
@@ -57,6 +64,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import javax.imageio.ImageIO;
 import org.eclipse.jgit.lib.ObjectId;
@@ -81,11 +89,14 @@
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private DiffOperations diffOperations;
+  @Inject private ProjectOperations projectOperations;
 
   private boolean intraline;
   private boolean useNewDiffCacheListFiles;
   private boolean useNewDiffCacheGetDiff;
 
+  private ObjectId initialCommit;
   private ObjectId commit1;
   private String changeId;
   private String initialPatchSetId;
@@ -104,6 +115,8 @@
         baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    initialCommit = headCommit;
+
     commit1 =
         addCommit(headCommit, ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
 
@@ -124,10 +137,33 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
-  @Ignore
   @Test
   public void diffWithRootCommit() throws Exception {
-    // TODO(ghareeb): Implement this test
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
+
+    testRepo.reset(initialCommit);
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of("f.txt", "content"))
+            .noParent();
+    push.setForce(true);
+    PushOneCommit.Result result = push.to("refs/heads/master");
+
+    Map<String, FileDiffOutput> modifiedFiles =
+        diffOperations.listModifiedFilesAgainstParent(project, result.getCommit(), null);
+
+    assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
+    assertThat(
+            modifiedFiles.values().stream()
+                .map(FileDiffOutput::oldCommitId)
+                .collect(Collectors.toSet()))
+        .containsExactly(EMPTY_TREE_ID);
+    assertThat(modifiedFiles.get("/COMMIT_MSG").changeType()).isEqualTo(Patch.ChangeType.ADDED);
+    assertThat(modifiedFiles.get("f.txt").changeType()).isEqualTo(Patch.ChangeType.ADDED);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 23bcdec..31292d5 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -36,7 +36,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -396,7 +396,7 @@
         .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
         .update();
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     String pushSpec = "refs/for/master%reviewer=" + user.email();
     sender.clear();
 
@@ -433,7 +433,7 @@
         .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
         .update();
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     String pushSpec = "refs/for/master%reviewer=" + user.email() + ",cc=" + user.email();
 
     TestAccount user2 = accountCreator.user2();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index bf8de93..650334f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -63,10 +63,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 796ce38..d967f48 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -85,8 +85,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 36cd3cb..be94cdf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -147,7 +147,7 @@
                 + "' only matches inactive accounts. To use an inactive account, retry with one"
                 + " of the following exact account IDs:\n"
                 + user.id()
-                + ": User <user@example.com>");
+                + ": User1 <user1@example.com>");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 5b31fd8..d480eb1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
@@ -71,6 +72,7 @@
 
 @NoHttpd
 @UseClockStep(clockStepUnit = TimeUnit.MINUTES)
+@VerifyNoPiiInChangeNotes(true)
 public class AttentionSetIT extends AbstractDaemonTest {
 
   @Inject private ChangeOperations changeOperations;
@@ -703,7 +705,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
@@ -720,7 +722,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
@@ -757,7 +759,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 6bffdf7..6a748a5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -51,20 +51,20 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_OwnerACLNotGranted() throws Exception {
     assertApproveFails(user, createMyChange(testRepo));
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_OwnerACLGranted() throws Exception {
     grantApproveToChangeOwner(project);
     approve(user, createMyChange(testRepo));
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_NotOwnerACLGranted() throws Exception {
     grantApproveToChangeOwner(project);
     assertApproveFails(user2, createMyChange(testRepo));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index ed21050..695bb90 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -60,14 +60,14 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void updateProjectConfig() throws Exception {
     String id = testUpdateProjectConfig();
     assertThat(gApi.changes().id(id).get().revisions).hasSize(1);
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user", submitType = SubmitType.CHERRY_PICK)
+  @TestProjectInput(cloneAs = "user1", submitType = SubmitType.CHERRY_PICK)
   public void updateProjectConfigWithCherryPick() throws Exception {
     String id = testUpdateProjectConfig();
     assertThat(gApi.changes().id(id).get().revisions).hasSize(2);
@@ -102,7 +102,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void onlyAdminMayUpdateProjectParent() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     ProjectInput parent = new ProjectInput();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
index 2cb96e8..29dd227 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -20,14 +20,21 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInfoDifference;
+import com.google.inject.Inject;
+import java.util.Collection;
 import org.junit.Test;
 
 public class GetMetaDiffIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String UNSAVED_REV_ID = "0000000000000000000000000000000000000001";
   private static final String TOPIC = "topic";
@@ -197,4 +204,22 @@
     assertThat(difference.added().currentRevision).isEqualTo(newInfo.currentRevision);
     assertThat(difference.removed().currentRevision).isEqualTo(oldInfo.currentRevision);
   }
+
+  @Test
+  public void staticField() throws Exception {
+    PushOneCommit.Result result = createChange();
+    ReviewInput in = new ReviewInput();
+    in.message("hello");
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(result.getChangeId()).revision("current").review(in);
+    ChangeApi chApi = gApi.changes().id(result.getChangeId());
+    ChangeInfoDifference difference = chApi.metaDiff(null, null, ListChangesOption.LABELS);
+    assertThat(difference.added().reviewers).containsKey(ReviewerState.CC);
+    assertThat(difference.added().reviewers).hasSize(1);
+    Collection<AccountInfo> reviewers = difference.added().reviewers.get(ReviewerState.CC);
+    assertThat(reviewers).hasSize(1);
+    AccountInfo info = reviewers.iterator().next();
+    assertThat(info._accountId).isEqualTo(user.id().get());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index def4ed8..99aa273 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -67,7 +67,7 @@
     TestAccount user2 = accountCreator.user2();
     AccountGroup.UUID groupId = groupOperations.newGroup().name("test").create();
     String group = groupOperations.group(groupId).get().name();
-    gApi.groups().id(group).addMembers("admin", "user", user2.username());
+    gApi.groups().id(group).addMembers("admin", user.username(), user2.username());
 
     // Create a project and restrict its visibility to the group
     Project.NameKey p = projectOperations.newProject().create();
@@ -103,7 +103,7 @@
 
     // Remove the user from the group so they can no longer see the project
     requestScopeOperations.setApiUser(admin.id());
-    gApi.groups().id(group).removeMembers("user");
+    gApi.groups().id(group).removeMembers(user.username());
 
     // User can no longer see the change
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 66eb48c..58e48e9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -20,6 +20,7 @@
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -107,6 +108,39 @@
   }
 
   @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void submitTwoIndependentChangesWithFastForwardFail() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    PushOneCommit.Result change1 = createChange("subject1", "file1.txt", "content", "topic");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("subject2", "file2.txt", "content", "topic");
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+
+    String fastForwardIndependentChangesError =
+        "Change could not be merged because the submission"
+            + " has two independent changes with the same destination branch. Independent changes can't "
+            + "be submitted to the same destination branch with FAST_FORWARD_ONLY submit strategy";
+
+    submitWithConflict(
+        change2.getChangeId(),
+        String.format(
+            "Failed to submit 2 changes due to the following problems:\n"
+                + "Change %d: %s\nChange %d: %s",
+            change1.getChange().getId().get(),
+            fastForwardIndependentChangesError,
+            change2.getChange().getId().get(),
+            fastForwardIndependentChangesError));
+
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
+    assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
   public void submitFastForwardNotPossible_Conflict() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 6a98b8b..dfe69f9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -430,6 +430,57 @@
   }
 
   @Test
+  public void createWithCopyCondition() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "is:MAX";
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyCondition).isEqualTo("is:MAX");
+  }
+
+  @Test
+  public void createCopyConditionPerformsGroupVisibilityCheckWhenUserInPredicateIsUsed()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "uploaderin:" + administratorsUUID;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    // User can't see admin group
+    requestScopeOperations.setApiUser(user.id());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("Group " + administratorsUUID + " not found");
+
+    // Admin can see admin group
+    requestScopeOperations.setApiUser(admin.id());
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(updatedLabel.copyCondition).isEqualTo(input.copyCondition);
+  }
+
+  @Test
+  public void createWithInvalidCopyCondition() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "blarg::asd";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Bar").create(input));
+    assertThat(thrown).hasMessageThat().contains("unable to parse copy condition");
+  }
+
+  @Test
   public void createWithCopyMaxScore() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index 2e68b54..b4938c1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -516,6 +516,83 @@
   }
 
   @Test
+  public void setCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "is:MAX";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isEqualTo("is:MAX");
+  }
+
+  @Test
+  public void setCopyConditionPerformsGroupVisibilityCheckWhenUserInPredicateIsUsed()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "uploaderin:" + administratorsUUID;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    // User can't see admin group
+    requestScopeOperations.setApiUser(user.id());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").update(input));
+    assertThat(thrown).hasMessageThat().contains("Group " + administratorsUUID + " not found");
+
+    // Admin can see admin group
+    requestScopeOperations.setApiUser(admin.id());
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isEqualTo(input.copyCondition);
+  }
+
+  @Test
+  public void setInvalidCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "foo:::bar";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").update(input));
+    assertThat(thrown).hasMessageThat().contains("unable to parse copy condition");
+  }
+
+  @Test
+  public void unsetCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyCondition("is:MAX"));
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition)
+        .isEqualTo("is:MAX");
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.unsetCopyCondition = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+  }
+
+  @Test
   public void setCopyMinScore() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 00aacbb5..cbf8438 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1453,7 +1453,7 @@
             .to("refs/for/master");
 
     // Add drafts with user scope
-    requestScopeOperations.setApiUser(accountCreator.user().id());
+    requestScopeOperations.setApiUser(accountCreator.user1().id());
     CommentInfo draft =
         addDraft(
             r1.getChangeId(),
@@ -1477,7 +1477,7 @@
         .isEqualTo(String.format("Non-existing draft IDs: [%s]", draft.id));
 
     // Request will succeed if done by user
-    requestScopeOperations.setApiUser(accountCreator.user().id());
+    requestScopeOperations.setApiUser(accountCreator.user1().id());
     gApi.changes().id(r1.getChangeId()).current().review(reviewInput);
     assertThat(
             gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList().stream()
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index 49b184b..df66e9f 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -48,7 +48,7 @@
   @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
-      values = {".+ser@example\\.com", "a@b\\.com"})
+      values = {".+ser1@example\\.com", "a@b\\.com"})
   public void listFilterAllowDoesNotFilterListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
new file mode 100644
index 0000000..23047a4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -0,0 +1,265 @@
+// Copyright (C) 2021 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.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsEvaluatorIT extends AbstractDaemonTest {
+  @Inject SubmitRequirementsEvaluator evaluator;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+
+  private ChangeData changeData;
+  private String changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit.Result pushResult =
+        createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
+    changeData = pushResult.getChange();
+    changeId = pushResult.getChangeId();
+  }
+
+  @Test
+  public void invalidExpression() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("invalid_field:invalid_value");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.ERROR);
+    assertThat(result.errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void expressionWithPassingPredicate() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("branch:refs/heads/master");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.PASS);
+    assertThat(result.errorMessage()).isEqualTo(Optional.empty());
+  }
+
+  @Test
+  public void expressionWithFailingPredicate() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("branch:refs/heads/foo");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.FAIL);
+    assertThat(result.errorMessage()).isEqualTo(Optional.empty());
+  }
+
+  @Test
+  public void compositeExpression() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create(
+            String.format(
+                "(project:%s AND branch:refs/heads/foo) OR message:\"Fix a bug\"", project.get()));
+
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.PASS);
+
+    assertThat(result.getPassingAtoms())
+        .containsExactly(
+            PredicateResult.builder()
+                .predicateString(String.format("project:%s", project.get()))
+                .status(true)
+                .build(),
+            PredicateResult.builder()
+                .predicateString("message:\"Fix a bug\"")
+                .status(true)
+                .build());
+
+    assertThat(result.getFailingAtoms())
+        .containsExactly(
+            PredicateResult.builder()
+                // TODO(ghareeb): querying "branch:" creates a RefPredicate. Fix names so that they
+                // match
+                .predicateString(String.format("ref:refs/heads/foo"))
+                .status(false)
+                .build());
+  }
+
+  @Test
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
+      throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:non-existent-project",
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsTrue() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "message:\"Fix a bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsUnsatisfied_whenSubmittabilityExpressionIsFalse()
+      throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsTrue() throws Exception {
+    addLabel("build-cop-override");
+    voteLabel(changeId, "build-cop-override", 1);
+
+    // Reload change data after applying the vote
+    changeData =
+        changeQueryProvider.get().byLegacyChangeId(changeData.getId()).stream()
+            .collect(MoreCollectors.onlyElement());
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void submitRequirementIsError_whenApplicabilityExpressionHasInvalidSyntax()
+      throws Exception {
+    addLabel("build-cop-override");
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "invalid_field:invalid_value",
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.applicabilityExpressionResult().get().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void submitRequirementIsError_whenSubmittabilityExpressionHasInvalidSyntax()
+      throws Exception {
+    addLabel("build-cop-override");
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "invalid_field:invalid_value",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.submittabilityExpressionResult().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void submitRequirementIsError_whenOverrideExpressionHasInvalidSyntax() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "invalid_field:invalid_value");
+
+    SubmitRequirementResult result = evaluator.evaluate(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.overrideExpressionResult().get().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
+  }
+
+  private void addLabel(String labelName) throws Exception {
+    configLabel(
+        project,
+        labelName,
+        LabelFunction.NO_OP,
+        value(1, "ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"));
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(labelName).ref("refs/heads/master").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+  }
+
+  private SubmitRequirement createSubmitRequirement(
+      @Nullable String applicabilityExpr,
+      String submittabilityExpr,
+      @Nullable String overrideExpr) {
+    return SubmitRequirement.builder()
+        .setName("sr-name")
+        .setDescription(Optional.of("sr-description"))
+        .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+        .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+        .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+        .setAllowOverrideInChildProjects(false)
+        .build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
new file mode 100644
index 0000000..9392219
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -0,0 +1,274 @@
+// Copyright (C) 2021 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.acceptance.server.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.approval.ApprovalContext;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.inject.Inject;
+import java.util.Date;
+import org.junit.Test;
+
+public class ApprovalQueryIT extends AbstractDaemonTest {
+  @Inject private ApprovalQueryBuilder queryBuilder;
+  @Inject private ChangeKindCreator changeKindCreator;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ChangeKindCache changeKindCache;
+  @Inject private ChangeOperations changeOperations;
+
+  @Test
+  public void magicValuePredicate() throws Exception {
+    assertTrue(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertTrue(queryBuilder.parse("is:mAx").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(1)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(5000)));
+
+    assertTrue(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertTrue(queryBuilder.parse("is:mIn").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(-1)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(5000)));
+
+    assertTrue(queryBuilder.parse("is:ANY").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertTrue(queryBuilder.parse("is:ANY").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertTrue(queryBuilder.parse("is:aNy").asMatchable().match(contextForCodeReviewLabel(2)));
+  }
+
+  @Test
+  public void changeKindPredicate_noCodeChange() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.NO_CODE_CHANGE, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.NO_CODE_CHANGE, testRepo, admin, project);
+    PatchSet.Id ps1 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:no-code-change")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.TRIVIAL_REBASE, testRepo, admin, project);
+    PatchSet.Id ps2 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("changekind:no-code-change")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void changeKindPredicate_trivialRebase() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.TRIVIAL_REBASE, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.TRIVIAL_REBASE, testRepo, admin, project);
+    PatchSet.Id ps1 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:trivial-rebase")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps2 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("changekind:trivial-rebase")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void changeKindPredicate_reworkAndNotRework() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.REWORK, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps1 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:rework")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps2 =
+        PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* psId= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("-changekind:rework")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void uploaderInPredicate() throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    PushOneCommit.Result pushResult = createChange();
+    String changeCreatedByAdmin = pushResult.getChangeId();
+    approve(changeCreatedByAdmin);
+    // PS2 uploaded by admin
+    amendChange(changeCreatedByAdmin);
+    // PS3 uploaded by user
+    amendChangeWithUploader(pushResult, project, user).assertOkStatus();
+
+    // can copy approval from patchset 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("uploaderin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* psId= */ 1),
+                    admin.id())));
+    // can not copy approval from patchset 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("uploaderin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* psId= */ 2),
+                    admin.id())));
+  }
+
+  @Test
+  public void approverInPredicate() throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    PushOneCommit.Result pushResult = createChange();
+    amendChange(pushResult.getChangeId());
+    amendChange(pushResult.getChangeId());
+    // can copy approval from patchset 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("approverin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* psId= */ 1),
+                    admin.id())));
+    // can not copy approval from patchset 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("approverin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* psId= */ 2),
+                    user.id())));
+  }
+
+  @Test
+  public void userInPredicate_groupNotFound() {
+    QueryParseException thrown =
+        assertThrows(
+            QueryParseException.class,
+            () ->
+                queryBuilder
+                    .parse("uploaderin:foobar")
+                    .asMatchable()
+                    .match(contextForCodeReviewLabel(/* value= */ 2)));
+    assertThat(thrown).hasMessageThat().contains("Group foobar not found");
+  }
+
+  @Test
+  public void hasChangedFilesPredicate() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+
+    // can copy approval from patch-set 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("has:unchanged-files")
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2, PatchSet.id(changeId, /* psId= */ 1), admin.id())));
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+
+    // can not copy approval from patch-set 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("has:unchanged-files")
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2, PatchSet.id(changeId, /* psId= */ 2), admin.id())));
+  }
+
+  @Test
+  public void hasChangedFilesPredicate_unsupportedOperator() {
+    QueryParseException thrown =
+        assertThrows(
+            QueryParseException.class,
+            () ->
+                queryBuilder
+                    .parse("has:invalid")
+                    .asMatchable()
+                    .match(contextForCodeReviewLabel(/* value= */ 2)));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "'invalid' is not a supported argument for has. only 'unchanged-files' is supported");
+  }
+
+  private ApprovalContext contextForCodeReviewLabel(int value) throws Exception {
+    PushOneCommit.Result result = createChange();
+    amendChange(result.getChangeId());
+    PatchSet.Id psId = PatchSet.id(result.getChange().getId(), 1);
+    return contextForCodeReviewLabel(value, psId, admin.id());
+  }
+
+  private ApprovalContext contextForCodeReviewLabel(
+      int value, PatchSet.Id psId, Account.Id approver) {
+    ChangeNotes changeNotes = changeNotesFactory.create(project, psId.changeId());
+    PatchSet.Id newPsId = PatchSet.id(psId.changeId(), psId.get() + 1);
+    ChangeKind changeKind =
+        changeKindCache.getChangeKind(
+            changeNotes.getChange(), changeNotes.getPatchSets().get(newPsId));
+    PatchSetApproval approval =
+        PatchSetApproval.builder()
+            .postSubmit(false)
+            .granted(new Date())
+            .key(PatchSetApproval.key(psId, approver, LabelId.create("Code-Review")))
+            .value(value)
+            .build();
+    return ApprovalContext.create(changeNotes, approval, newPsId, changeKind);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/query/BUILD b/javatests/com/google/gerrit/acceptance/server/query/BUILD
new file mode 100644
index 0000000..f7d13a0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/query/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_query",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 09b0438..4352fe8 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -18,9 +18,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.client.ReviewerState;
 import java.lang.reflect.Field;
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Map;
 import org.junit.Test;
@@ -305,6 +307,32 @@
     buildObjectWithFullFields(ChangeInfo.class);
   }
 
+  @Test
+  public void getDiff_arrayListInMap() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+
+    AccountInfo i1 = new AccountInfo();
+    i1._accountId = 1;
+    AccountInfo i2 = new AccountInfo();
+    i2._accountId = 2;
+
+    ArrayList<AccountInfo> a1 = new ArrayList<>();
+    ArrayList<AccountInfo> a2 = new ArrayList<>();
+
+    a1.add(i1);
+    a2.add(i1);
+    a2.add(i2);
+    oldChangeInfo.reviewers = ImmutableMap.of(ReviewerState.REVIEWER, a1);
+    newChangeInfo.reviewers = ImmutableMap.of(ReviewerState.REVIEWER, a2);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+    assertThat(diff.added().reviewers).hasSize(1);
+    assertThat(diff.added().reviewers).containsKey(ReviewerState.REVIEWER);
+    assertThat(diff.added().reviewers.get(ReviewerState.REVIEWER)).containsExactly(i2);
+    assertThat(diff.removed().reviewers).isNull();
+  }
+
   private static Object buildObjectWithFullFields(Class<?> c) throws Exception {
     if (c == null) {
       return null;
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index f9df375..b114acc 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -114,7 +114,7 @@
       setUpUserAuthentication(admin.username());
 
       // Create non-admin user
-      TestAccount user = accountCreator.user();
+      TestAccount user = accountCreator.user1();
       setUpUserAuthentication(user.username());
 
       // Prepare data for new change on master branch
diff --git a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
index c33ef8e..ad8a486 100644
--- a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
+++ b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
@@ -64,7 +64,7 @@
   private void setup(ServerContext ctx) throws Exception {
     ctx.getInjector().injectMembers(this);
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     gApi.accounts().id(user.username()).setHttpPassword(PASSWORD);
 
     String canonical = config.getString("gerrit", null, "canonicalweburl");
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index ad460cd..614dcf0 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -35,6 +35,7 @@
           .setIgnoreSelfApproval(!LabelType.DEF_IGNORE_SELF_APPROVAL)
           .setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
           .setDefaultValue((short) 1)
+          .setCopyCondition("is:ANY")
           .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
           .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
           .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
@@ -57,7 +58,8 @@
 
   @Test
   public void roundTripWithMinimalValues() {
-    LabelType autoValue = ALL_VALUES_SET.toBuilder().setRefPatterns(null).build();
+    LabelType autoValue =
+        ALL_VALUES_SET.toBuilder().setRefPatterns(null).setCopyCondition(null).build();
     assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
index c2e8e0c..a1dee1a 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -29,7 +29,7 @@
           .setName("code-review")
           .setDescription(Optional.of("require code review +2"))
           .setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
-          .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
+          .setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
           .setOverrideExpression(Optional.empty())
           .setAllowOverrideInChildProjects(true)
           .build();
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
new file mode 100644
index 0000000..f105cf1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -0,0 +1,124 @@
+// Copyright (C) 2021 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.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeNotesCommitTest extends AbstractChangeNotesTest {
+  private TestRepository<InMemoryRepository> testRepo;
+  private ChangeNotesCommit.ChangeNotesRevWalk walk;
+
+  @Before
+  public void setUpTestRepo() throws Exception {
+    testRepo = new TestRepository<>(repo);
+    walk = ChangeNotesCommit.newRevWalk(repo);
+  }
+
+  @After
+  public void tearDownTestRepo() throws Exception {
+    walk.close();
+  }
+
+  @Test
+  public void attentionSetCommitOnlyWhenNoChangeMessageIsPresentAndCorrectFooter()
+      throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(true);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenNoChangeMessageIsPresentAndFooterNotOnlyAS()
+      throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Subject: Change subject\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenNoChangeMessageIsPresentAndGenericFooter()
+      throws Exception {
+    RevCommit commit = writeCommit("Update patch set 1\n" + "\n" + "Patch-set: 1\n");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenChangeMessageIsPresent() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(true)).isEqualTo(false);
+  }
+
+  private ChangeNotesParser newParser(ObjectId tip) throws Exception {
+    walk.reset();
+    ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
+    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+  }
+
+  private RevCommit writeCommit(String body) throws Exception {
+    Change change = newChange(true);
+    ChangeNotes notes = newNotes(change).load();
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    PersonIdent author =
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent);
+    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
+      CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(notes.getRevision());
+      cb.setAuthor(author);
+      cb.setCommitter(new PersonIdent(serverIdent, author.getWhen()));
+      cb.setTreeId(testRepo.tree());
+      cb.setMessage(body);
+      ObjectId id = ins.insert(cb);
+      ins.flush();
+      RevCommit commit = walk.parseCommit(id);
+      walk.parseBody(commit);
+      return commit;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 5bfe97c..6a32fa1 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -497,6 +497,73 @@
   }
 
   @Test
+  public void attentionSetOnlyShouldNotCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void attentionSetWithExtraFooterShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Subject: Change subject\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void changeWithoutAttentionSetShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void attentionSetWithCommentShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = true;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
   public void caseInsensitiveFooters() throws Exception {
     assertParseSucceeds(
         "Update change\n"
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
new file mode 100644
index 0000000..dbd1939
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2021 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.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class ChangeUpdateTest extends AbstractChangeNotesTest {
+
+  @Test
+  public void bypassMaxUpdatesShouldBeTrueWhenChangingAttentionSetOnly() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    // Add to attention set
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(
+            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isTrue();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeTrueWhenClosingChange() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.setStatus(Change.Status.ABANDONED);
+
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isTrue();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenNotAbandoningChangeAndNotChangingAttentionSetOnly()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenCommentsAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    // Add to attention set
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(
+            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+
+    // Add a comment
+    RevCommit commit = tr.commit().message("PS2").create();
+    update.putComment(
+        HumanComment.Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit,
+            false));
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
new file mode 100644
index 0000000..e4c2196
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2021 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.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+
+public class OpenRepoTest extends AbstractChangeNotesTest {
+
+  private final Optional<Integer> NO_UPDATES_AT_ALL = Optional.of(0);
+  private final Optional<Integer> ONLY_ONE_UPDATE = Optional.of(1);
+  private final Optional<Integer> ONLY_TWO_UPDATES = Optional.of(2);
+  private final Optional<Integer> MAX_PATCH_SETS = Optional.empty();
+
+  private FakeChainedReceiveCommands fakeChainedReceiveCommands;
+
+  @Override
+  public void setUpTestEnvironment() throws Exception {
+    super.setUpTestEnvironment();
+    fakeChainedReceiveCommands = new FakeChainedReceiveCommands(repo);
+  }
+
+  @Test
+  public void throwExceptionWhenExceedingMaxUpdatesLimit() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void allowExceedingLimitWhenAttentionSetUpdateOnly() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.NEW);
+
+      // Add to attention set
+      AttentionSetUpdate attentionSetUpdate =
+          AttentionSetUpdate.createForWrite(
+              otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+      update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void attentionSetUpdateShouldNotContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      // Add to attention set
+      AttentionSetUpdate attentionSetUpdate =
+          AttentionSetUpdate.createForWrite(
+              otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+      update1.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      openRepo.addUpdates(changeUpdates, ONLY_TWO_UPDATES, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void normalChangeShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, ONLY_ONE_UPDATE, MAX_PATCH_SETS));
+    }
+  }
+
+  private static class FakeChainedReceiveCommands extends ChainedReceiveCommands {
+    Map<String, ReceiveCommand> commands = new HashMap<>();
+
+    public FakeChainedReceiveCommands(Repository repo) {
+      super(repo);
+    }
+
+    @Override
+    public void add(ReceiveCommand cmd) {
+      commands.put(cmd.getRefName(), cmd);
+    }
+  }
+
+  private OpenRepo openRepo() {
+    return new OpenRepo(repo, rw, null, fakeChainedReceiveCommands, false);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 9dad9ae..2e934e9 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -212,11 +212,11 @@
                 "[submitRequirement \"Code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicabilityExpression = branch(refs/heads/master)\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
                     + "[submitRequirement \"api-review\"]\n"
                     + "  description =  Additional review required for API modifications\n"
                     + "  applicabilityExpression = commit_filepath_contains(\\\"/api/.*\\\")\n"
-                    + "  blockingExpression = label(api-review, +2)\n"
+                    + "  submittabilityExpression = label(api-review, +2)\n"
                     + "  overrideExpression = label(build-cop-override, +1)\n"
                     + "  canOverrideInChildProjects = true\n")
             .create();
@@ -231,7 +231,8 @@
                 .setDescription(Optional.of("At least one Code Review +2"))
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("branch(refs/heads/master)"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setOverrideExpression(Optional.empty())
                 .setAllowOverrideInChildProjects(false)
                 .build(),
@@ -241,7 +242,8 @@
                 .setDescription(Optional.of("Additional review required for API modifications"))
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("commit_filepath_contains(\"/api/.*\")"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(api-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(api-review, +2)"))
                 .setOverrideExpression(
                     SubmitRequirementExpression.of("label(build-cop-override, +1)"))
                 .setAllowOverrideInChildProjects(true)
@@ -256,7 +258,7 @@
             .add(
                 "project.config",
                 "[submitRequirement \"code-review\"]\n"
-                    + "  blockingExpression = label(code-review, +2)\n")
+                    + "  submittabilityExpression = label(code-review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -266,7 +268,8 @@
             "code-review",
             SubmitRequirement.builder()
                 .setName("code-review")
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
   }
@@ -280,10 +283,10 @@
                 "project.config",
                 "[submitRequirement \"code-review\"]\n"
                     + "  description = At least one Code Review +2\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
                     + "[submitRequirement \"Code-Review\"]\n"
                     + "  description = Another code review label\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
                     + "  canOverrideInChildProjects = true\n")
             .create();
 
@@ -295,7 +298,8 @@
             SubmitRequirement.builder()
                 .setName("code-review")
                 .setDescription(Optional.of("At least one Code Review +2"))
-                .setBlockingExpression(SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
     assertThat(cfg.getValidationErrors()).hasSize(1);
@@ -307,7 +311,7 @@
   }
 
   @Test
-  public void readSubmitRequirementNoBlockingExpression() throws Exception {
+  public void readSubmitRequirementNoSubmittabilityExpression() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("groups", group(developers))
@@ -323,7 +327,7 @@
     assertThat(cfg.getValidationErrors()).hasSize(1);
     assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
         .isEqualTo(
-            "project.config: Submit requirement \"code-review\" does not define a blocking expression."
+            "project.config: Submit requirement \"code-review\" does not define a submittability expression."
                 + " Skipping this requirement.");
   }
 
@@ -942,7 +946,7 @@
                 "[submitRequirement \"code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicabilityExpression = branch(refs/heads/master)\n"
-                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "  submittabilityExpression = label(code-review, +2)\n"
                     + "[notify \"name\"]\n"
                     + "  email = example@example.com\n")
             .create();
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index f6b3317..b7be40b 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -607,7 +607,6 @@
   @Test
   public void reindex() throws Exception {
     AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
-
     // update account without reindex so that account index is stale
     Account.Id accountId = Account.id(user1._accountId);
     String newName = "Test User";
@@ -621,10 +620,11 @@
           .setAccountDelta(AccountDelta.builder().setFullName(newName).build())
           .commit(md);
     }
-
-    assertQuery("name:" + quote(user1.name), user1);
-    assertQuery("name:" + quote(newName));
-
+    // Querying for the account here will not result in a stale document because
+    // we load AccountStates from the cache after reading documents from the index
+    // which means we always read fresh data when matching.
+    //
+    // Reindex document
     gApi.accounts().id(user1.username).index();
     assertQuery("name:" + quote(user1.name));
     assertQuery("name:" + quote(newName), user1);
diff --git a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
index b742bd8..31d256e 100644
--- a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
@@ -14,10 +14,6 @@
 
 package com.google.gerrit.server.query.account;
 
-import static org.junit.Assume.assumeFalse;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -25,7 +21,6 @@
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.sql.Timestamp;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
@@ -52,16 +47,4 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
-
-  @Override
-  protected void validateAssumptions() {
-    // TODO(hiesel): Account predicates are always matching (they return true on match), so we need
-    // to skip all tests here. We are doing this to document existing behavior. We want to remove
-    // this assume statement and make group predicates matchable.
-    assumeFalse(
-        AccountPredicates.equalsName("test")
-            .asMatchable()
-            .match(
-                AccountState.forAccount(Account.builder(Account.id(1), new Timestamp(0)).build())));
-  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 385d4b2..1e23420 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -18,8 +18,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
-import org.junit.Ignore;
-import org.junit.Test;
 
 /**
  * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex}. This test might seem
@@ -34,156 +32,4 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
-
-  @Ignore
-  @Test
-  @Override
-  public void byDefault() throws Exception {
-    // TODO(hiesel): Fix bug in predicate and remove @Ignore.
-    super.byDefault();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMergedBefore() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byMergedBefore();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void reviewerAndCcByEmail() throws Exception {
-    // TODO(hiesel): Fix bug in predicate and remove @Ignore.
-    super.reviewerAndCcByEmail();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMessageExact() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byMessageExact();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void fullTextWithNumbers() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.fullTextWithNumbers();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byTriplet() throws Exception {
-    // TODO(hiesel): Fix bug in predicate and remove @Ignore.
-    super.byTriplet();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byAge() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byAge();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMessageSubstring() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byMessageSubstring();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byBeforeUntil() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byBeforeUntil();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byTopic() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byTopic();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void userQuery() throws Exception {
-    // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
-    super.userQuery();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void visible() throws Exception {
-    // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
-    super.visible();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void userDestination() throws Exception {
-    // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
-    super.userDestination();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byAfterSince() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byAfterSince();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMessageMixedCase() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byMessageMixedCase();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byCommit() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byCommit();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byComment() throws Exception {
-    // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
-    super.byComment();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byMergedAfter() throws Exception {
-    // TODO(hiesel): Used predicate is not a matchable. Fix.
-    super.byMergedAfter();
-  }
-
-  @Ignore
-  @Test
-  @Override
-  public void byOwnerInvalidQuery() throws Exception {
-    // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
-    super.byMergedAfter();
-  }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
index 8bc1c30..e4f228a 100644
--- a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.query.group;
 
-import static org.junit.Assume.assumeTrue;
-
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -48,12 +46,4 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
-
-  @Override
-  protected void validateAssumptions() {
-    // TODO(hiesel): Group predicates are not matchable, so we need to skip all tests here.
-    // We are doing this to document existing behavior. We want to remove this assume statement and
-    // make group predicates matchable.
-    assumeTrue(GroupPredicates.inname("test").isMatchable());
-  }
 }
diff --git a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
index 8517ad2..6fc0568 100644
--- a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.query.project;
 
-import static org.junit.Assume.assumeTrue;
-
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -50,12 +48,4 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
-
-  @Override
-  protected void validateAssumptions() {
-    // TODO(hiesel): Project predicates are not matchable, so we need to skip all tests here.
-    // We are doing this to document existing behavior. We want to remove this assume statement and
-    // make group predicates matchable.
-    assumeTrue(ProjectPredicates.inname("test").isMatchable());
-  }
 }
diff --git a/plugins/replication b/plugins/replication
index 13cefb7..dc9bb2e 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 13cefb724df786d254ecbc24261589ab473be267
+Subproject commit dc9bb2e946e4c6c31e8a4665f30eca6d00017523
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index fb0390a..35e6449 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit fb0390a8b49f0d601e11f8a1ac0658c429727f21
+Subproject commit 35e6449a517691a880c94e7467bc07360f8e6666
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 454b3d5..2682158 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {CommentRange} from './core';
 
 export declare interface ChecksPluginApi {
   /**
@@ -382,6 +383,11 @@
   links?: Link[];
 
   /**
+   * Links to lines of code. The referenced path must be part of this patchset.
+   */
+  codePointers?: CodePointer[];
+
+  /**
    * Callbacks to the plugin. Must be implemented individually by each
    * plugin. Actions are rendered as buttons. If there are more than two actions
    * per result, then further actions are put into an overflow menu. Sort order
@@ -430,6 +436,11 @@
   icon: LinkIcon;
 }
 
+export declare interface CodePointer {
+  path: string;
+  range: CommentRange;
+}
+
 export enum LinkIcon {
   EXTERNAL = 'external',
   IMAGE = 'image',
@@ -438,4 +449,5 @@
   DOWNLOAD_MOBILE = 'download_mobile',
   HELP_PAGE = 'help_page',
   REPORT_BUG = 'report_bug',
+  CODE = 'code',
 }
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index 5820139..af7fc40 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -45,3 +45,25 @@
   /** The character position in the end line. (0-based) */
   end_character: number;
 }
+
+/**
+ * Return type for cursor moves, that indicate whether a move was possible.
+ */
+export enum CursorMoveResult {
+  /** The cursor was successfully moved. */
+  MOVED,
+  /** There were no stops - the cursor was reset. */
+  NO_STOPS,
+  /**
+   * There was no more matching stop to move to - the cursor was clipped to the
+   * end.
+   */
+  CLIPPED,
+  /** The abort condition would have been fulfilled for the new target. */
+  ABORTED,
+}
+
+/** A sentinel that can be inserted to disallow moving across. */
+export class AbortStop {}
+
+export type Stop = HTMLElement | AbortStop;
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 8e1ffef..ad83fb8 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -20,7 +20,7 @@
  * limitations under the License.
  */
 
-import {CommentRange} from './core';
+import {CommentRange, CursorMoveResult} from './core';
 
 /**
  * Diff type in preferences
@@ -287,6 +287,11 @@
   lineNum: LineNumber;
 }
 
+export declare interface LineNumberEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+}
+
 /** All types of button for expanding diff sections */
 export enum ContextButtonType {
   ABOVE = 'above',
@@ -400,3 +405,38 @@
     className: string
   ): void;
 }
+
+// The current setup requires API users to register GrDiff instances with the
+// cursor, but we do not at this point want to expose the API that GrDiffCursor
+// uses to the public as it is likely to change. So for now, we allow any type
+// and cast. This works fine so long as API users do provide whatever the
+// gr-diff tag creates.
+export type GrDiff = unknown;
+
+/** A service to interact with the line cursor in gr-diff instances. */
+export declare interface GrDiffCursor {
+  replaceDiffs(diffs: GrDiff[]): void;
+  unregisterDiff(diff: GrDiff): void;
+
+  isAtStart(): boolean;
+  isAtEnd(): boolean;
+
+  moveLeft(): void;
+  moveRight(): void;
+
+  moveDown(): CursorMoveResult;
+  moveUp(): CursorMoveResult;
+
+  moveToFirstChunk(): void;
+  moveToLastChunk(): void;
+
+  moveToNextChunk(): CursorMoveResult;
+  moveToPreviousChunk(): CursorMoveResult;
+
+  moveToNextCommentThread(): CursorMoveResult;
+  moveToPreviousCommentThread(): CursorMoveResult;
+
+  createCommentInPlace(): void;
+  resetScrollMode(): void;
+  moveToLineNumber(lineNum: number, side: Side, path?: string): void;
+}
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index b9918d3..b1b7f34 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -20,12 +20,13 @@
  * limitations under the License.
  */
 
-import {DiffLayer, GrAnnotation} from './diff';
+import {DiffLayer, GrAnnotation, GrDiffCursor} from './diff';
 
 declare global {
   interface Window {
     grdiff: {
       GrAnnotation: GrAnnotation;
+      GrDiffCursor: {new (): GrDiffCursor};
       TokenHighlightLayer: {new (): DiffLayer};
     };
   }
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0738825..d22026a 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -90,3 +90,9 @@
   // This measures the same interval as ExpandAllDiffs, but the result is divided by the number of diffs expanded.
   FILE_EXPAND_ALL_AVG = 'ExpandAllPerDiff',
 }
+
+export enum Interaction {
+  TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
+  SHOW_TAB = 'show-tab',
+  ATTENTION_SET_CHIP = 'attention-set-chip',
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index 8a4cbbe..a9de24a 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -118,19 +118,15 @@
     assert.isTrue(saveStub.called);
   });
 
-  test('_getRepoBranchesSuggestions empty', done => {
-    element._getRepoBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
+  test('_getRepoBranchesSuggestions empty', async () => {
+    const branches = await element._getRepoBranchesSuggestions('nonexistent');
+    assert.equal(branches.length, 0);
   });
 
-  test('_getRepoBranchesSuggestions non-empty', done => {
-    element._getRepoBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
+  test('_getRepoBranchesSuggestions non-empty', async () => {
+    const branches = await element._getRepoBranchesSuggestions('test-branch');
+    assert.equal(branches.length, 1);
+    assert.equal(branches[0].name, 'test-branch');
   });
 
   test('_computeBranchClass', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index c8e058e..4864af5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -23,20 +23,22 @@
 
 const basicFixture = fixtureFromElement('gr-repo-list');
 
-let counter;
-const repoGenerator = () => {
+function createRepo(name, counter) {
   return {
-    id: `test${++counter}`,
-    name: `test`,
+    id: `${name}${counter}`,
+    name: `${name}`,
     state: 'ACTIVE',
     web_links: [
       {
         name: 'diffusion',
-        url: `https://phabricator.example.org/r/project/test${counter}`,
+        url: `https://phabricator.example.org/r/project/${name}${counter}`,
       },
     ],
   };
-};
+}
+
+let counter;
+const repoGenerator = () => createRepo('test', ++counter);
 
 suite('gr-repo-list tests', () => {
   let element;
@@ -123,6 +125,15 @@
         done();
       });
     });
+
+    test('filter is case insensitive', async () => {
+      const repoStub = stubRestApi('getRepos');
+      const repos = [createRepo('aSDf', 0)];
+      repoStub.withArgs('asdf').returns(Promise.resolve(repos));
+      element._filter = 'asdf';
+      await element._getRepos('asdf', 25, 0);
+      assert.equal(element._repos.length, 1);
+    });
   });
 
   suite('loading', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 2869750..fa3c9c6 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -48,6 +48,7 @@
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {pluralize} from '../../../utils/string-util';
+import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 
 enum ChangeSize {
   XS = 10,
@@ -105,7 +106,7 @@
   changeURL?: string;
 
   @property({type: Array, computed: '_changeStatuses(change)'})
-  statuses?: string[];
+  statuses?: ChangeStates[];
 
   @property({type: Boolean})
   showStar = false;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index d6f4bdd..41a7598 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -252,7 +252,7 @@
       })
       .catch(err => {
         fireTitleChange(this, title || this._computeTitle(user));
-        console.warn(err);
+        this.reporting.error(err);
       })
       .then(() => {
         this._loading = false;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
index 1924a66..42a6847 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
@@ -29,7 +29,7 @@
   </style>
   <gr-avatar
     account="[[_accountDetails]]"
-    image-size="100"
+    imageSize="100"
     aria-label="Account avatar"
   ></gr-avatar>
   <div class="info">
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 1790651..02e4161 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -30,6 +30,7 @@
 } from '../../../test/test-data-generators';
 import {ChangeStatus, HttpMethod} from '../../../constants/constants';
 import {
+  mockPromise,
   query,
   queryAll,
   queryAndAssert,
@@ -144,13 +145,11 @@
       return element.reload();
     });
 
-    test('show-revision-actions event should fire', done => {
+    test('show-revision-actions event should fire', async () => {
       const spy = sinon.spy(element, '_sendShowRevisionActions');
       element.reload();
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
+      await flush();
+      assert.isTrue(spy.called);
     });
 
     test('primary and secondary actions split properly', () => {
@@ -198,7 +197,7 @@
       );
     });
 
-    test('plugin revision actions', done => {
+    test('plugin revision actions', async () => {
       const stub = stubRestApi('getChangeActionURL').returns(
         Promise.resolve('the-url')
       );
@@ -206,20 +205,18 @@
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
-      flush(() => {
-        assert.isTrue(
-          stub.calledWith(
-            element.changeNum,
-            element.latestPatchNum,
-            '/plugin~action'
-          )
-        );
-        assert.equal(
-          (element.revisionActions['plugin~action'] as UIActionInfo)!.__url,
-          'the-url'
-        );
-        done();
-      });
+      await flush();
+      assert.isTrue(
+        stub.calledWith(
+          element.changeNum,
+          element.latestPatchNum,
+          '/plugin~action'
+        )
+      );
+      assert.equal(
+        (element.revisionActions['plugin~action'] as UIActionInfo)!.__url,
+        'the-url'
+      );
     });
 
     test('plugin change actions', async () => {
@@ -266,71 +263,61 @@
       );
     });
 
-    test('hide revision action', done => {
-      flush(() => {
-        const buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
-        assert.isOk(buttonEl);
-        element.setActionHidden(
-          element.ActionType.REVISION,
-          element.RevisionActions.SUBMIT,
-          true
-        );
-        assert.lengthOf(element._hiddenActions, 1);
-        element.setActionHidden(
-          element.ActionType.REVISION,
-          element.RevisionActions.SUBMIT,
-          true
-        );
-        assert.lengthOf(element._hiddenActions, 1);
-        flush(() => {
-          const buttonEl = element.shadowRoot?.querySelector(
-            '[data-action-key="submit"]'
-          );
-          assert.isNotOk(buttonEl);
+    test('hide revision action', async () => {
+      await flush();
+      let buttonEl: Element | undefined = queryAndAssert(
+        element,
+        '[data-action-key="submit"]'
+      );
+      assert.isOk(buttonEl);
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        true
+      );
+      assert.lengthOf(element._hiddenActions, 1);
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        true
+      );
+      assert.lengthOf(element._hiddenActions, 1);
+      await flush();
+      buttonEl = query(element, '[data-action-key="submit"]');
+      assert.isNotOk(buttonEl);
 
-          element.setActionHidden(
-            element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT,
-            false
-          );
-          flush(() => {
-            const buttonEl = queryAndAssert(
-              element,
-              '[data-action-key="submit"]'
-            );
-            assert.isFalse(buttonEl.hasAttribute('hidden'));
-            done();
-          });
-        });
-      });
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        false
+      );
+      await flush();
+      buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
+      assert.isFalse(buttonEl.hasAttribute('hidden'));
     });
 
-    test('buttons exist', done => {
+    test('buttons exist', async () => {
       element._loading = false;
-      flush(() => {
-        const buttonEls = queryAll(element, 'gr-button');
-        const menuItems = element.$.moreActions.items;
+      await flush();
+      const buttonEls = queryAll(element, 'gr-button');
+      const menuItems = element.$.moreActions.items;
 
-        // Total button number is one greater than the number of total actions
-        // due to the existence of the overflow menu trigger.
-        assert.equal(
-          buttonEls!.length + menuItems!.length,
-          element._allActionValues.length + 1
-        );
-        assert.isFalse(element.hidden);
-        done();
-      });
+      // Total button number is one greater than the number of total actions
+      // due to the existence of the overflow menu trigger.
+      assert.equal(
+        buttonEls!.length + menuItems!.length,
+        element._allActionValues.length + 1
+      );
+      assert.isFalse(element.hidden);
     });
 
-    test('delete buttons have explicit labels', done => {
-      flush(() => {
-        const deleteItems = element.$.moreActions.items!.filter(item =>
-          item.id!.startsWith('delete')
-        );
-        assert.equal(deleteItems.length, 1);
-        assert.equal(deleteItems[0].name, 'Delete change');
-        done();
-      });
+    test('delete buttons have explicit labels', async () => {
+      await flush();
+      const deleteItems = element.$.moreActions.items!.filter(item =>
+        item.id!.startsWith('delete')
+      );
+      assert.equal(deleteItems.length, 1);
+      assert.equal(deleteItems[0].name, 'Delete change');
     });
 
     test('get revision object from change', () => {
@@ -369,7 +356,7 @@
       assert.deepEqual(result, actions);
     });
 
-    test('submit change', () => {
+    test('submit change', async () => {
       const showSpy = sinon.spy(element, '_showActionDialog');
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
@@ -390,12 +377,15 @@
       );
       tap(submitButton);
 
-      flush();
+      await flush();
       assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
     });
 
-    test('submit change, tap on icon', done => {
-      sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake(done);
+    test('submit change, tap on icon', async () => {
+      const submitted = mockPromise();
+      sinon
+        .stub(element.$.confirmSubmitDialog, 'resetFocus')
+        .callsFake(() => submitted.resolve());
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
@@ -414,6 +404,7 @@
         'gr-button[data-action-key="submit"] iron-icon'
       );
       tap(submitIcon);
+      await submitted;
     });
 
     test('_handleSubmitConfirm', () => {
@@ -435,19 +426,16 @@
       assert.isFalse(fireStub.called);
     });
 
-    test('submit change with plugin hook', done => {
+    test('submit change with plugin hook', async () => {
       sinon.stub(element, '_canSubmitChange').callsFake(() => false);
       const fireActionStub = sinon.stub(element, '_fireAction');
-      flush(() => {
-        const submitButton = queryAndAssert(
-          element,
-          'gr-button[data-action-key="submit"]'
-        );
-        tap(submitButton);
-        assert.equal(fireActionStub.callCount, 0);
-
-        done();
-      });
+      await flush();
+      const submitButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"]'
+      );
+      tap(submitButton);
+      assert.equal(fireActionStub.callCount, 0);
     });
 
     test('chain state', () => {
@@ -490,55 +478,51 @@
       );
     });
 
-    test('rebase change', done => {
+    test('rebase change', async () => {
       const fireActionStub = sinon.stub(element, '_fireAction');
       const fetchChangesStub = sinon
         .stub(element.$.confirmRebase, 'fetchRecentChanges')
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = queryAndAssert(
-          element,
-          'gr-button[data-action-key="rebase"]'
-        );
-        tap(rebaseButton);
-        const rebaseAction = {
-          __key: 'rebase',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Rebase',
-          method: HttpMethod.POST,
-          title: 'Rebase onto tip of branch or parent change',
-        };
-        assert.isTrue(fetchChangesStub.called);
-        element._handleRebaseConfirm(
-          new CustomEvent('', {detail: {base: '1234'}})
-        );
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/rebase',
-          assertUIActionInfo(rebaseAction),
-          true,
-          {base: '1234'},
-        ]);
-        done();
-      });
+      await flush();
+      const rebaseButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      tap(rebaseButton);
+      const rebaseAction = {
+        __key: 'rebase',
+        __type: 'revision',
+        __primary: false,
+        enabled: true,
+        label: 'Rebase',
+        method: HttpMethod.POST,
+        title: 'Rebase onto tip of branch or parent change',
+      };
+      assert.isTrue(fetchChangesStub.called);
+      element._handleRebaseConfirm(
+        new CustomEvent('', {detail: {base: '1234'}})
+      );
+      assert.deepEqual(fireActionStub.lastCall.args, [
+        '/rebase',
+        assertUIActionInfo(rebaseAction),
+        true,
+        {base: '1234'},
+      ]);
     });
 
-    test('rebase change fires reload event', done => {
+    test('rebase change fires reload event', async () => {
       const eventStub = sinon.stub(element, 'dispatchEvent');
       element._handleResponse(
         {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
         new Response()
       );
-      flush(() => {
-        assert.isTrue(eventStub.called);
-        assert.equal(eventStub.lastCall.args[0].type, 'reload');
-        done();
-      });
+      await flush();
+      assert.isTrue(eventStub.called);
+      assert.equal(eventStub.lastCall.args[0].type, 'reload');
     });
 
-    test("rebase dialog gets recent changes each time it's opened", done => {
+    test("rebase dialog gets recent changes each time it's opened", async () => {
       const fetchChangesStub = sinon
         .stub(element.$.confirmRebase, 'fetchRecentChanges')
         .returns(Promise.resolve([]));
@@ -550,17 +534,15 @@
       tap(rebaseButton);
       assert.isTrue(fetchChangesStub.calledOnce);
 
-      flush(() => {
-        element.$.confirmRebase.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true,
-            bubbles: true,
-          })
-        );
-        tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledTwice);
-        done();
-      });
+      await flush();
+      element.$.confirmRebase.dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledTwice);
     });
 
     test('two dialogs are not shown at the same time', async () => {
@@ -624,7 +606,7 @@
     });
 
     suite('change edits', () => {
-      test('disableEdit', () => {
+      test('disableEdit', async () => {
         element.set('editMode', false);
         element.set('editPatchsetLoaded', false);
         element.change = {
@@ -632,7 +614,7 @@
           status: ChangeStatus.NEW,
         };
         element.set('disableEdit', true);
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -647,7 +629,7 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('shows confirm dialog for delete edit', () => {
+      test('shows confirm dialog for delete edit', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
 
@@ -660,12 +642,12 @@
             'gr-button[primary]'
           )
         );
-        flush();
+        await flush();
 
         assert.equal(fireActionStub.lastCall.args[0], '/edit');
       });
 
-      test('edit patchset is loaded, needs rebase', () => {
+      test('edit patchset is loaded, needs rebase', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
         element.change = {
@@ -673,7 +655,7 @@
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = false;
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -684,7 +666,7 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit patchset is loaded, does not need rebase', () => {
+      test('edit patchset is loaded, does not need rebase', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', true);
         element.change = {
@@ -692,7 +674,7 @@
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = true;
-        flush();
+        await flush();
 
         assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
         assert.isNotOk(
@@ -703,14 +685,14 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit mode is loaded, no edit patchset', () => {
+      test('edit mode is loaded, no edit patchset', async () => {
         element.set('editMode', true);
         element.set('editPatchsetLoaded', false);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -725,14 +707,14 @@
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('normal patch set', () => {
+      test('normal patch set', async () => {
         element.set('editMode', false);
         element.set('editPatchsetLoaded', false);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -747,16 +729,17 @@
         assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
       });
 
-      test('edit action', done => {
+      test('edit action', async () => {
+        const editTapped = mockPromise();
         element.addEventListener('edit-tap', () => {
-          done();
+          editTapped.resolve();
         });
         element.set('editMode', true);
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
@@ -764,7 +747,7 @@
           ...createChangeViewChange(),
           status: ChangeStatus.MERGED,
         };
-        flush();
+        await flush();
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         element.change = {
@@ -772,13 +755,14 @@
           status: ChangeStatus.NEW,
         };
         element.set('editMode', false);
-        flush();
+        await flush();
 
         const editButton = queryAndAssert(
           element,
           'gr-button[data-action-key="edit"]'
         );
         tap(editButton);
+        await editTapped;
       });
     });
 
@@ -896,62 +880,57 @@
             status: ChangeStatus.NEW,
           },
         ];
-        setup(done => {
+        setup(async () => {
           stubRestApi('getChanges').returns(Promise.resolve(changes));
           element._handleCherrypickTap();
-          flush(() => {
-            const radioButtons = queryAll(
-              element.$.confirmCherrypick,
-              "input[name='cherryPickOptions']"
-            );
-            assert.equal(radioButtons.length, 2);
-            tap(radioButtons[1]);
-            flush(() => {
-              done();
-            });
-          });
+          await flush();
+          const radioButtons = queryAll(
+            element.$.confirmCherrypick,
+            "input[name='cherryPickOptions']"
+          );
+          assert.equal(radioButtons.length, 2);
+          tap(radioButtons[1]);
+          await flush();
         });
 
-        test('cherry pick topic dialog is rendered', done => {
+        test('cherry pick topic dialog is rendered', async () => {
           const dialog = element.$.confirmCherrypick;
-          flush(() => {
-            const changesTable = queryAndAssert(dialog, 'table');
-            const headers = Array.from(changesTable.querySelectorAll('th'));
-            const expectedHeadings = [
-              '',
-              'Change',
-              'Status',
-              'Subject',
-              'Project',
-              'Progress',
-              '',
-            ];
-            const headings = headers.map(header => header.innerText);
-            assert.equal(headings.length, expectedHeadings.length);
-            for (let i = 0; i < headings.length; i++) {
-              assert.equal(headings[i].trim(), expectedHeadings[i]);
-            }
-            const changeRows = queryAll(changesTable, 'tbody > tr');
-            const change = Array.from(changeRows[0].querySelectorAll('td')).map(
-              e => e.innerText
-            );
-            const expectedChange = [
-              '',
-              '1234567890',
-              'MERGED',
-              'random',
-              'A',
-              'NOT STARTED',
-              '',
-            ];
-            for (let i = 0; i < change.length; i++) {
-              assert.equal(change[i].trim(), expectedChange[i]);
-            }
-            done();
-          });
+          await flush();
+          const changesTable = queryAndAssert(dialog, 'table');
+          const headers = Array.from(changesTable.querySelectorAll('th'));
+          const expectedHeadings = [
+            '',
+            'Change',
+            'Status',
+            'Subject',
+            'Project',
+            'Progress',
+            '',
+          ];
+          const headings = headers.map(header => header.innerText);
+          assert.equal(headings.length, expectedHeadings.length);
+          for (let i = 0; i < headings.length; i++) {
+            assert.equal(headings[i].trim(), expectedHeadings[i]);
+          }
+          const changeRows = queryAll(changesTable, 'tbody > tr');
+          const change = Array.from(changeRows[0].querySelectorAll('td')).map(
+            e => e.innerText
+          );
+          const expectedChange = [
+            '',
+            '1234567890',
+            'MERGED',
+            'random',
+            'A',
+            'NOT STARTED',
+            '',
+          ];
+          for (let i = 0; i < change.length; i++) {
+            assert.equal(change[i].trim(), expectedChange[i]);
+          }
         });
 
-        test('changes with duplicate project show an error', done => {
+        test('changes with duplicate project show an error', async () => {
           const dialog = element.$.confirmCherrypick;
           const error = queryAndAssert(
             dialog,
@@ -974,13 +953,11 @@
               project: 'A' as RepoName,
             },
           ]);
-          flush(() => {
-            assert.equal(
-              error.innerText,
-              'Two changes cannot be of the same' + ' project'
-            );
-            done();
-          });
+          await flush();
+          assert.equal(
+            error.innerText,
+            'Two changes cannot be of the same' + ' project'
+          );
         });
       });
     });
@@ -1021,24 +998,24 @@
       });
     });
 
-    test('custom actions', done => {
+    test('custom actions', async () => {
       // Add a button with the same key as a server-based one to ensure
       // collisions are taken care of.
       const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', e => {
+      const keyTapped = mockPromise();
+      element.addEventListener(key + '-tap', async e => {
         assert.equal(
           (e as CustomEvent).detail.node.getAttribute('data-action-key'),
           key
         );
         element.removeActionButton(key);
-        flush(() => {
-          assert.notOk(query(element, '[data-action-key="' + key + '"]'));
-          done();
-        });
+        await flush();
+        assert.notOk(query(element, '[data-action-key="' + key + '"]'));
+        keyTapped.resolve();
       });
-      flush(() => {
-        tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
-      });
+      await flush();
+      tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+      await keyTapped;
     });
 
     test('_setLoadingOnButtonWithKey top-level', () => {
@@ -1095,32 +1072,28 @@
         return element.reload();
       });
 
-      test('abandon change with message', done => {
+      test('abandon change with message', async () => {
         const newAbandonMsg = 'Test Abandon Message';
         element.$.confirmAbandonDialog.message = newAbandonMsg;
-        flush(() => {
-          const abandonButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="abandon"]'
-          );
-          tap(abandonButton);
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
 
-          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-          done();
-        });
+        assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
       });
 
-      test('abandon change with no message', done => {
-        flush(() => {
-          const abandonButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="abandon"]'
-          );
-          tap(abandonButton);
+      test('abandon change with no message', async () => {
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
 
-          assert.isUndefined(element.$.confirmAbandonDialog.message);
-          done();
-        });
+        assert.isUndefined(element.$.confirmAbandonDialog.message);
       });
 
       test('works', () => {
@@ -1173,7 +1146,7 @@
         return element.reload();
       });
 
-      test('revert change with plugin hook', done => {
+      test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
           .stub(element.$.confirmRevertDialog, '_modifyRevertMsg')
@@ -1204,17 +1177,14 @@
             '_populateRevertSubmissionMessage'
           )
           .callsFake(() => 'original msg');
-        flush(() => {
-          const revertButton = queryAndAssert(
-            element,
-            'gr-button[data-action-key="revert"]'
-          );
-          tap(revertButton);
-          flush(() => {
-            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
-            done();
-          });
-        });
+        await flush();
+        const revertButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="revert"]'
+        );
+        tap(revertButton);
+        await flush();
+        assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
       });
 
       suite('revert change submitted together', () => {
@@ -1243,60 +1213,56 @@
           );
         });
 
-        test('confirm revert dialog shows both options', done => {
+        test('confirm revert dialog shows both options', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           tap(revertButton);
-          flush(() => {
-            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const revertSingleChangeLabel = queryAndAssert(
-              confirmRevertDialog,
-              '.revertSingleChange'
-            ) as HTMLLabelElement;
-            const revertSubmissionLabel = queryAndAssert(
-              confirmRevertDialog,
-              '.revertSubmission'
-            ) as HTMLLabelElement;
-            assert(
-              revertSingleChangeLabel.innerText.trim() ===
-                'Revert single change'
-            );
-            assert(
-              revertSubmissionLabel.innerText.trim() ===
-                'Revert entire submission (2 Changes)'
-            );
-            let expectedMsg =
-              'Revert submission 199 0' +
-              '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' +
-              '\n' +
-              'Reverted Changes:' +
-              '\n' +
-              '1234567890:random' +
-              '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            assert.equal(confirmRevertDialog._message, expectedMsg);
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            tap(radioInputs[0]);
-            flush(() => {
-              expectedMsg =
-                'Revert "random commit message"\n\nThis reverts ' +
-                'commit 2000.\n\nReason' +
-                ' for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, expectedMsg);
-              done();
-            });
-          });
+          await flush();
+          assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const revertSingleChangeLabel = queryAndAssert(
+            confirmRevertDialog,
+            '.revertSingleChange'
+          ) as HTMLLabelElement;
+          const revertSubmissionLabel = queryAndAssert(
+            confirmRevertDialog,
+            '.revertSubmission'
+          ) as HTMLLabelElement;
+          assert(
+            revertSingleChangeLabel.innerText.trim() === 'Revert single change'
+          );
+          assert(
+            revertSubmissionLabel.innerText.trim() ===
+              'Revert entire submission (2 Changes)'
+          );
+          let expectedMsg =
+            'Revert submission 199 0' +
+            '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' +
+            '\n' +
+            'Reverted Changes:' +
+            '\n' +
+            '1234567890:random' +
+            '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+          assert.equal(confirmRevertDialog._message, expectedMsg);
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          tap(radioInputs[0]);
+          await flush();
+          expectedMsg =
+            'Revert "random commit message"\n\nThis reverts ' +
+            'commit 2000.\n\nReason' +
+            ' for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, expectedMsg);
         });
 
-        test('submit fails if message is not edited', done => {
+        test('submit fails if message is not edited', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
@@ -1304,69 +1270,58 @@
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
+          await flush();
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isFalse(fireStub.called);
         });
 
-        test('message modification is retained on switching', done => {
+        test('message modification is retained on switching', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
-          flush(() => {
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            const revertSubmissionMsg =
-              'Revert submission 199 0' +
-              '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' +
-              '\n' +
-              'Reverted Changes:' +
-              '\n' +
-              '1234567890:random' +
-              '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            const singleChangeMsg =
-              'Revert "random commit message"\n\nThis reverts ' +
-              'commit 2000.\n\nReason' +
-              ' for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
-            const newRevertMsg = revertSubmissionMsg + 'random';
-            const newSingleChangeMsg = singleChangeMsg + 'random';
-            confirmRevertDialog._message = newRevertMsg;
-            tap(radioInputs[0]);
-            flush(() => {
-              assert.equal(confirmRevertDialog._message, singleChangeMsg);
-              confirmRevertDialog._message = newSingleChangeMsg;
-              tap(radioInputs[1]);
-              flush(() => {
-                assert.equal(confirmRevertDialog._message, newRevertMsg);
-                tap(radioInputs[0]);
-                flush(() => {
-                  assert.equal(
-                    confirmRevertDialog._message,
-                    newSingleChangeMsg
-                  );
-                  done();
-                });
-              });
-            });
-          });
+          await flush();
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          const revertSubmissionMsg =
+            'Revert submission 199 0' +
+            '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' +
+            '\n' +
+            'Reverted Changes:' +
+            '\n' +
+            '1234567890:random' +
+            '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+          const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts ' +
+            'commit 2000.\n\nReason' +
+            ' for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+          const newRevertMsg = revertSubmissionMsg + 'random';
+          const newSingleChangeMsg = singleChangeMsg + 'random';
+          confirmRevertDialog._message = newRevertMsg;
+          tap(radioInputs[0]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, singleChangeMsg);
+          confirmRevertDialog._message = newSingleChangeMsg;
+          tap(radioInputs[1]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, newRevertMsg);
+          tap(radioInputs[0]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, newSingleChangeMsg);
         });
       });
 
@@ -1389,7 +1344,7 @@
           );
         });
 
-        test('submit fails if message is not edited', done => {
+        test('submit fails if message is not edited', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
@@ -1397,55 +1352,46 @@
           const confirmRevertDialog = element.$.confirmRevertDialog;
           tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
+          await flush();
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isFalse(fireStub.called);
         });
 
-        test('confirm revert dialog shows no radio button', done => {
+        test('confirm revert dialog shows no radio button', async () => {
           const revertButton = queryAndAssert(
             element,
             'gr-button[data-action-key="revert"]'
           );
           tap(revertButton);
-          flush(() => {
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const radioInputs = queryAll(
-              confirmRevertDialog,
-              'input[name="revertOptions"]'
-            );
-            assert.equal(radioInputs.length, 0);
-            const msg =
-              'Revert "random commit message"\n\n' +
-              'This reverts commit 2000.\n\nReason ' +
-              'for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, msg);
-            const editedMsg = msg + 'hello';
-            confirmRevertDialog._message += 'hello';
-            const confirmButton = queryAndAssert(
-              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
-              '#confirm'
-            );
-            tap(confirmButton);
-            flush(() => {
-              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
-              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
-              assert.equal(
-                fireActionStub.getCall(0).args[3].message,
-                editedMsg
-              );
-              done();
-            });
-          });
+          await flush();
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          assert.equal(radioInputs.length, 0);
+          const msg =
+            'Revert "random commit message"\n\n' +
+            'This reverts commit 2000.\n\nReason ' +
+            'for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, msg);
+          const editedMsg = msg + 'hello';
+          confirmRevertDialog._message += 'hello';
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+          assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+          assert.equal(fireActionStub.getCall(0).args[3].message, editedMsg);
         });
       });
     });
@@ -1477,27 +1423,23 @@
       test(
         'make sure the mark private change button is not outside of the ' +
           'overflow menu',
-        done => {
-          flush(() => {
-            assert.isNotOk(query(element, '[data-action-key="private"]'));
-            done();
-          });
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private"]'));
         }
       );
 
-      test('private change', done => {
-        flush(() => {
-          assert.isOk(
-            query(element.$.moreActions, 'span[data-id="private-change"]')
-          );
-          element.setActionOverflow(ActionType.CHANGE, 'private', false);
-          flush();
-          assert.isOk(query(element, '[data-action-key="private"]'));
-          assert.isNotOk(
-            query(element.$.moreActions, 'span[data-id="private-change"]')
-          );
-          done();
-        });
+      test('private change', async () => {
+        await flush();
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="private-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'private', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="private"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="private-change"]')
+        );
       });
     });
 
@@ -1528,35 +1470,23 @@
       test(
         'make sure the unmark private change button is not outside of the ' +
           'overflow menu',
-        done => {
-          flush(() => {
-            assert.isNotOk(
-              query(element, '[data-action-key="private.delete"]')
-            );
-            done();
-          });
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
         }
       );
 
-      test('unmark the private change', done => {
-        flush(() => {
-          assert.isOk(
-            query(
-              element.$.moreActions,
-              'span[data-id="private.delete-change"]'
-            )
-          );
-          element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
-          flush();
-          assert.isOk(query(element, '[data-action-key="private.delete"]'));
-          assert.isNotOk(
-            query(
-              element.$.moreActions,
-              'span[data-id="private.delete-change"]'
-            )
-          );
-          done();
-        });
+      test('unmark the private change', async () => {
+        await flush();
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="private.delete"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+        );
       });
     });
 
@@ -1586,7 +1516,7 @@
         assert.isFalse(fireActionStub.called);
       });
 
-      test('shows confirm dialog', () => {
+      test('shows confirm dialog', async () => {
         element._handleDeleteTap();
         assert.isFalse(
           (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
@@ -1597,11 +1527,11 @@
             'gr-button[primary]'
           )
         );
-        flush();
+        await flush();
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
 
-      test('hides delete confirm on cancel', () => {
+      test('hides delete confirm on cancel', async () => {
         element._handleDeleteTap();
         tap(
           queryAndAssert(
@@ -1609,7 +1539,7 @@
             'gr-button:not([primary])'
           )
         );
-        flush();
+        await flush();
         assert.isTrue(
           (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
         );
@@ -1618,7 +1548,7 @@
     });
 
     suite('ignore change', () => {
-      setup(done => {
+      setup(async () => {
         sinon.stub(element, '_fireAction');
 
         const IgnoreAction = {
@@ -1638,19 +1568,18 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        element.reload().then(() => {
-          flush(done);
-        });
+        await element.reload();
+        await flush();
       });
 
       test('make sure the ignore button is not outside of the overflow menu', () => {
         assert.isNotOk(query(element, '[data-action-key="ignore"]'));
       });
 
-      test('ignoring change', () => {
+      test('ignoring change', async () => {
         queryAndAssert(element.$.moreActions, 'span[data-id="ignore-change"]');
         element.setActionOverflow(ActionType.CHANGE, 'ignore', false);
-        flush();
+        await flush();
         queryAndAssert(element, '[data-action-key="ignore"]');
         assert.isNotOk(
           query(element.$.moreActions, 'span[data-id="ignore-change"]')
@@ -1659,7 +1588,7 @@
     });
 
     suite('unignore change', () => {
-      setup(done => {
+      setup(async () => {
         sinon.stub(element, '_fireAction');
 
         const UnignoreAction = {
@@ -1679,21 +1608,20 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        element.reload().then(() => {
-          flush(done);
-        });
+        await element.reload();
+        await flush();
       });
 
       test('unignore button is not outside of the overflow menu', () => {
         assert.isNotOk(query(element, '[data-action-key="unignore"]'));
       });
 
-      test('unignoring change', () => {
+      test('unignoring change', async () => {
         assert.isOk(
           query(element.$.moreActions, 'span[data-id="unignore-change"]')
         );
         element.setActionOverflow(ActionType.CHANGE, 'unignore', false);
-        flush();
+        await flush();
         assert.isOk(query(element, '[data-action-key="unignore"]'));
         assert.isNotOk(
           query(element.$.moreActions, 'span[data-id="unignore-change"]')
@@ -1702,7 +1630,7 @@
     });
 
     suite('quick approve', () => {
-      setup(() => {
+      setup(async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1719,7 +1647,7 @@
             foo: ['-1', ' 0', '+1'],
           },
         };
-        flush();
+        await flush();
       });
 
       test('added when can approve', () => {
@@ -1730,7 +1658,7 @@
         assert.isNotNull(approveButton);
       });
 
-      test('hide quick approve', () => {
+      test('hide quick approve', async () => {
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1740,7 +1668,7 @@
 
         // Assert approve button gets removed from list of buttons.
         element.hideQuickApproveAction();
-        flush();
+        await flush();
         const approveButtonUpdated = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1756,18 +1684,21 @@
         assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
       });
 
-      test('not added when change is merged', () => {
-        element.change!.status = ChangeStatus.MERGED;
-        flush(() => {
-          const approveButton = query(
-            element,
-            "gr-button[data-action-key='review']"
-          );
-          assert.isNotOk(approveButton);
-        });
+      test('not added when change is merged', async () => {
+        element.change = {
+          ...element.change!,
+          status: ChangeStatus.MERGED,
+        };
+
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
       });
 
-      test('not added when already approved', () => {
+      test('not added when already approved', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1781,7 +1712,7 @@
             foo: [' 0', '+1'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1789,7 +1720,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('not added when label not permitted', () => {
+      test('not added when label not permitted', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1800,7 +1731,7 @@
             bar: [],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1808,17 +1739,17 @@
         assert.isNotOk(approveButton);
       });
 
-      test('approves when tapped', () => {
+      test('approves when tapped', async () => {
         const fireActionStub = sinon.stub(element, '_fireAction');
         tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
-        flush();
+        await flush();
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
         const payload = fireActionStub.lastCall.args[3];
         assert.deepEqual((payload as ReviewInput).labels, {foo: 1});
       });
 
-      test('not added when multiple labels are required without code review', () => {
+      test('not added when multiple labels are required without code review', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1831,7 +1762,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1839,7 +1770,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('code review shown with multiple missing approval', () => {
+      test('code review shown with multiple missing approval', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1861,7 +1792,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1869,7 +1800,7 @@
         assert.isOk(approveButton);
       });
 
-      test('button label for missing approval', () => {
+      test('button label for missing approval', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1887,7 +1818,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1895,7 +1826,7 @@
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
-      test('no quick approve if score is not maximal for a label', () => {
+      test('no quick approve if score is not maximal for a label', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1913,7 +1844,7 @@
             bar: [' 0', '+1'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1921,7 +1852,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('approving label with a non-max score', () => {
+      test('approving label with a non-max score', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1939,7 +1870,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1947,7 +1878,7 @@
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
 
-      test('added when can approve an already-approved code review label', () => {
+      test('added when can approve an already-approved code review label', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1965,7 +1896,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1973,7 +1904,7 @@
         assert.isNotNull(approveButton);
       });
 
-      test('not added when the user has already approved', () => {
+      test('not added when the user has already approved', async () => {
         const vote = {
           ...createApproval(),
           _account_id: 123 as AccountId,
@@ -1998,7 +1929,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -2006,7 +1937,7 @@
         assert.isNotOk(approveButton);
       });
 
-      test('not added when user owns the change', () => {
+      test('not added when user owns the change', async () => {
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -2025,7 +1956,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        flush();
+        await flush();
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -2034,12 +1965,12 @@
       });
     });
 
-    test('adds download revision action', () => {
+    test('adds download revision action', async () => {
       const handler = sinon.stub();
       element.addEventListener('download-tap', handler);
       assert.ok(element.revisionActions.download);
       element._handleDownloadTap();
-      flush();
+      await flush();
 
       assert.isTrue(handler.called);
     });
@@ -2061,14 +1992,14 @@
     });
 
     suite('setActionOverflow', () => {
-      test('move action from overflow', () => {
+      test('move action from overflow', async () => {
         assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
         assert.strictEqual(
           element.$.moreActions!.items![0].id,
           'cherrypick-revision'
         );
         element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
-        flush();
+        await flush();
         assert.isOk(query(element, '[data-action-key="cherrypick"]'));
         assert.notEqual(
           element.$.moreActions!.items![0].id,
@@ -2076,10 +2007,10 @@
         );
       });
 
-      test('move action to overflow', () => {
+      test('move action to overflow', async () => {
         assert.isOk(query(element, '[data-action-key="submit"]'));
         element.setActionOverflow(ActionType.REVISION, 'submit', true);
-        flush();
+        await flush();
         assert.isNotOk(query(element, '[data-action-key="submit"]'));
         assert.strictEqual(
           element.$.moreActions.items![3].id,
@@ -2233,31 +2164,24 @@
             );
           });
 
-          test('revert submission single change', done => {
-            element
-              ._send(
-                HttpMethod.POST,
-                {message: 'Revert submission'},
-                '/revert_submission',
-                false,
-                cleanup,
-                {} as UIActionInfo
-              )
-              .then(() => {
-                element
-                  ._handleResponse(
-                    {
-                      __key: 'revert_submission',
-                      __type: ActionType.CHANGE,
-                      label: 'l',
-                    },
-                    new Response()
-                  )!
-                  .then(() => {
-                    assert.isTrue(navigateToSearchQueryStub.called);
-                    done();
-                  });
-              });
+          test('revert submission single change', async () => {
+            await element._send(
+              HttpMethod.POST,
+              {message: 'Revert submission'},
+              '/revert_submission',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element._handleResponse(
+              {
+                __key: 'revert_submission',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isTrue(navigateToSearchQueryStub.called);
           });
         });
 
@@ -2280,55 +2204,42 @@
             );
           });
 
-          test('revert submission multiple change', done => {
-            element
-              ._send(
-                HttpMethod.POST,
-                {message: 'Revert submission'},
-                '/revert_submission',
-                false,
-                cleanup,
-                {} as UIActionInfo
-              )
-              .then(() => {
-                element
-                  ._handleResponse(
-                    {
-                      __key: 'revert_submission',
-                      __type: ActionType.CHANGE,
-                      label: 'l',
-                    },
-                    new Response()
-                  )!
-                  .then(() => {
-                    assert.isFalse(showActionDialogStub.called);
-                    assert.isTrue(
-                      navigateToSearchQueryStub.calledWith('topic: T')
-                    );
-                    done();
-                  });
-              });
+          test('revert submission multiple change', async () => {
+            await element._send(
+              HttpMethod.POST,
+              {message: 'Revert submission'},
+              '/revert_submission',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element._handleResponse(
+              {
+                __key: 'revert_submission',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isFalse(showActionDialogStub.called);
+            assert.isTrue(navigateToSearchQueryStub.calledWith('topic: T'));
           });
         });
 
-        test('revision action', done => {
-          element
-            ._send(
-              HttpMethod.DELETE,
-              payload,
-              '/endpoint',
-              true,
-              cleanup,
-              {} as UIActionInfo
-            )
-            .then(() => {
-              assert.isFalse(onShowError.called);
-              assert.isTrue(cleanup.calledOnce);
-              assert.isTrue(
-                sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
-              );
-              done();
-            });
+        test('revision action', async () => {
+          await element._send(
+            HttpMethod.DELETE,
+            payload,
+            '/endpoint',
+            true,
+            cleanup,
+            {} as UIActionInfo
+          );
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(
+            sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
+          );
         });
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 3d55097..e1a1cbb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -86,6 +86,7 @@
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {Interaction} from '../../../constants/reporting';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -619,7 +620,7 @@
 
   _onShowAllClick() {
     this._showAllSections = !this._showAllSections;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'metadata',
       toState: this._showAllSections ? 'Show all' : 'Show less',
     });
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index 1d7db85..7fc3ddf 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -35,6 +35,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
 import {labelCompare} from '../../../utils/label-util';
+import {Interaction} from '../../../constants/reporting';
 
 interface ChangeRequirement extends Requirement {
   satisfied: boolean;
@@ -181,7 +182,7 @@
 
   _handleShowHide() {
     this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'optional labels',
       toState: this._showOptionalLabels ? 'Show all' : 'Show less',
     });
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 8d47ab5..c34a6ae 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
@@ -20,18 +20,19 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {appContext} from '../../../services/app-context';
 import {
-  allRunsLatest$,
+  allRunsLatestPatchsetLatestAttempt$,
   aPluginHasRegistered$,
   CheckResult,
   CheckRun,
-  errorMessage$,
-  loginCallback$,
-  someProvidersAreLoading$,
+  errorMessageLatest$,
+  loginCallbackLatest$,
+  someProvidersAreLoadingLatest$,
 } from '../../../services/checks/checks-model';
-import {Category, Link, RunStatus} from '../../../api/checks';
+import {Category, RunStatus} from '../../../api/checks';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import '../../shared/gr-avatar/gr-avatar';
 import {
+  firstPrimaryLink,
   getResultsOf,
   hasCompletedWithoutResults,
   hasResultsOf,
@@ -260,7 +261,7 @@
 }
 
 /** What is the maximum number of expanded checks chips? */
-const DETAILS_QUOTA = 3;
+const DETAILS_QUOTA = 2;
 
 @customElement('gr-change-summary')
 export class GrChangeSummary extends GrLitElement {
@@ -292,7 +293,7 @@
    * How many check chips may still be rendered as a detailed chip. Is reset
    * when rendering begins and decreases while chips are rendered. So when
    * there are two ERRORs, then those would consume 2 from this quota and then
-   * there would only by DETAILS_QUOTA - 2 left for the other summary chips.
+   * there would only be DETAILS_QUOTA - 2 left for the other summary chips.
    * Once there are more results than quota left we will stop rendering
    * detailed chips and fall back to just icon+number rendering.
    */
@@ -307,11 +308,11 @@
 
   constructor() {
     super();
-    this.subscribe('runs', allRunsLatest$);
+    this.subscribe('runs', allRunsLatestPatchsetLatestAttempt$);
     this.subscribe('showChecksSummary', aPluginHasRegistered$);
-    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
-    this.subscribe('errorMessage', errorMessage$);
-    this.subscribe('loginCallback', loginCallback$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoadingLatest$);
+    this.subscribe('errorMessage', errorMessageLatest$);
+    this.subscribe('loginCallback', loginCallbackLatest$);
   }
 
   static get styles() {
@@ -326,10 +327,9 @@
           margin-bottom: var(--spacing-m);
         }
         .zeroState {
-          color: var(--primary-text-color);
+          color: var(--deemphasized-text-color);
         }
         .loading.zeroState {
-          color: var(--deemphasized-text-color);
           margin-right: var(--spacing-m);
         }
         div.error {
@@ -455,13 +455,10 @@
       this.detailsQuota -= runs.length;
       return runs.map(run => {
         this.detailsCheckNames.push(run.checkName);
-        const allLinks = resultFilter(run)
-          .reduce(
-            (links, result) => links.concat(result.links ?? []),
-            [] as Link[]
-          )
-          .filter(link => link.primary);
-        const links = allLinks.length === 1 ? allLinks : [];
+        const allPrimaryLinks = resultFilter(run)
+          .map(firstPrimaryLink)
+          .filter(notUndefined);
+        const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
         const text = `${run.checkName}`;
         return html`<gr-checks-chip
           class="${icon}"
@@ -563,7 +560,7 @@
                   account =>
                     html`<gr-avatar
                       .account="${account}"
-                      image-size="32"
+                      imageSize="32"
                     ></gr-avatar>`
                 )}
                 ${countUnresolvedComments} unresolved</gr-summary-chip
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 f3b746a8..58cf489 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
@@ -72,7 +72,7 @@
   hasEditPatchsetLoaded,
   PatchSet,
 } from '../../../utils/patch-set-util';
-import {changeStatuses} from '../../../utils/change-util';
+import {changeStatuses, isOwner, isReviewer} from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
@@ -172,7 +172,7 @@
 import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {Subject} from 'rxjs';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 
@@ -429,7 +429,7 @@
     type: String,
     computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
   })
-  _changeStatuses?: string[];
+  _changeStatuses?: ChangeStates[];
 
   /** If false, then the "Show more" button was used to expand. */
   @property({type: Boolean})
@@ -634,7 +634,7 @@
           this._dynamicTabContentEndpoints.length !==
           this._dynamicTabHeaderEndpoints.length
         ) {
-          console.warn('Different number of tab headers and tab content.');
+          this.reporting.error(new Error('Mismatch of headers and content.'));
         }
       })
       .then(() => this._initActiveTabs(this.params));
@@ -767,7 +767,7 @@
       }
     }
     if (activeIndex === -1) {
-      console.warn('tab not found with given info', activeDetails);
+      this.reporting.error(new Error(`tab not found for ${activeDetails}`));
       return;
     }
     const tabName = tabs[activeIndex].dataset['name'];
@@ -777,7 +777,7 @@
     if (paperTabs.selected !== activeIndex) {
       // paperTabs.selected is undefined during rendering
       if (paperTabs.selected !== undefined) {
-        this.reporting.reportInteraction('show-tab', {tabName, src});
+        this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src});
       }
       paperTabs.selected = activeIndex;
     }
@@ -840,7 +840,7 @@
       if (hasUnresolvedThreads) this.unresolvedOnly = true;
     }
 
-    this.reporting.reportInteraction('show-tab', {
+    this.reporting.reportInteraction(Interaction.SHOW_TAB, {
       tabName,
       src: 'paper-tab-click',
     });
@@ -1225,10 +1225,7 @@
     this._shownFileCount = e.detail.length;
   }
 
-  _expandAllDiffs(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _expandAllDiffs() {
     this.$.fileList.expandAllDiffs();
   }
 
@@ -2133,7 +2130,10 @@
       .then(() => {
         this.reporting.timeEnd(Timing.CHANGE_RELOAD);
         if (isLocationChange) {
-          this.reporting.changeDisplayed();
+          this.reporting.changeDisplayed({
+            isOwner: isOwner(this._change, this._account),
+            isReviewer: isReviewer(this._change, this._account),
+          });
         }
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index b04792d..693406c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -99,7 +99,7 @@
       font-size: var(--font-size-mono);
       line-height: var(--line-height-mono);
       margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-m);
+      margin-bottom: var(--spacing-l);
       /* Account for border and padding and rounding errors. */
       max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
     }
@@ -197,8 +197,7 @@
       width: 100%;
     }
     gr-change-summary {
-      /* temporary for old checks status */
-      margin-bottom: var(--spacing-m);
+      margin-left: var(--spacing-m);
     }
     @media screen and (max-width: 75em) {
       .relatedChanges {
@@ -430,6 +429,7 @@
                   ></gr-linked-text>
                 </gr-editable-content>
               </div>
+              <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
               <gr-change-summary
                 change-comments="[[_changeComments]]"
                 comment-threads="[[_commentThreads]]"
@@ -465,7 +465,7 @@
       <paper-tab
         on-click="_onPaperTabClick"
         data-name$="[[_constants.PrimaryTab.FILES]]"
-        >Files</paper-tab
+        ><span>Files</span></paper-tab
       >
       <paper-tab
         on-click="_onPaperTabClick"
@@ -483,7 +483,7 @@
         <paper-tab
           data-name$="[[_constants.PrimaryTab.CHECKS]]"
           on-click="_onPaperTabClick"
-          >Checks</paper-tab
+          ><span>Checks</span></paper-tab
         >
       </template>
       <template
@@ -504,7 +504,7 @@
         data-name$="[[_constants.PrimaryTab.FINDINGS]]"
         on-click="_onPaperTabClick"
       >
-        Findings
+        <span>Findings</span>
       </paper-tab>
     </paper-tabs>
 
@@ -564,6 +564,7 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.COMMENT_THREADS, _activeTabs)]]"
       >
+        <h3 class="assistive-tech-only">Comments</h3>
         <gr-thread-list
           threads="[[_commentThreads]]"
           change="[[_change]]"
@@ -579,6 +580,7 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
       >
+        <h3 class="assistive-tech-only">Checks</h3>
         <gr-checks-tab
           id="checksTab"
           tab-state="[[_tabState.checksTab]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 3d51ac6..5128948 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -1222,7 +1222,7 @@
       },
     };
     element._mergeable = true;
-    const expectedStatuses = ['Merged', 'WIP'];
+    const expectedStatuses = [ChangeStates.MERGED, ChangeStates.WIP];
     assert.deepEqual(element._changeStatuses, expectedStatuses);
     flush();
     const statusChips = element.shadowRoot!.querySelectorAll(
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 532097b..ff303e3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -44,6 +44,7 @@
 import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -60,7 +61,7 @@
 }
 
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends PolymerElement {
+export class GrFileListHeader extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 69be729..8b9b80c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -173,6 +173,7 @@
           class="download"
           title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
                 ShortcutSection.ACTIONS)]]"
+          has-tooltip=""
           on-click="_handleDownloadTap"
           >Download</gr-button
         >
@@ -186,6 +187,7 @@
           link=""
           title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
                 ShortcutSection.FILE_LIST)]]"
+          has-tooltip=""
           on-click="_expandAllDiffs"
           >Expand All</gr-button
         >
@@ -195,6 +197,7 @@
           on-click="_collapseAllDiffs"
           title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
           ShortcutSection.FILE_LIST)]]"
+          has-tooltip=""
           >Collapse All</gr-button
         >
       </template>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index ca16ec0..5ce6e3b 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -95,7 +95,6 @@
 export interface GrFileList {
   $: {
     diffPreferencesDialog: GrDiffPreferencesDialog;
-    diffCursor: GrDiffCursor;
   };
 }
 
@@ -350,6 +349,8 @@
 
   private fileCursor = new GrCursorManager();
 
+  private diffCursor = new GrDiffCursor();
+
   constructor() {
     super();
     this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
@@ -384,31 +385,26 @@
           this._dynamicHeaderEndpoints.length !==
           this._dynamicContentEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list header and content.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
           this._dynamicPrependedHeaderEndpoints.length !==
           this._dynamicPrependedContentEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list header and content.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
           this._dynamicHeaderEndpoints.length !==
           this._dynamicSummaryEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list headers and summary.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
       });
   }
 
   /** @override */
   disconnectedCallback() {
+    this.diffCursor.dispose();
     this.fileCursor.unsetCursor();
     this._cancelDiffs();
     this.loadingTask?.cancel();
@@ -597,7 +593,7 @@
       this._expandedFiles.length,
       this._files.length
     );
-    this.$.diffCursor.handleDiffUpdate();
+    this.diffCursor.handleDiffUpdate();
   }
 
   /**
@@ -841,7 +837,7 @@
     }
 
     e.preventDefault();
-    this.$.diffCursor.moveLeft();
+    this.diffCursor.moveLeft();
   }
 
   _handleRightPane(e: CustomKeyboardEvent) {
@@ -850,7 +846,7 @@
     }
 
     e.preventDefault();
-    this.$.diffCursor.moveRight();
+    this.diffCursor.moveRight();
   }
 
   _handleToggleInlineDiff(e: CustomKeyboardEvent) {
@@ -891,7 +887,7 @@
 
     if (this._showInlineDiffs) {
       e.preventDefault();
-      this.$.diffCursor.moveDown();
+      this.diffCursor.moveDown();
       this._displayLine = true;
     } else {
       // Down key
@@ -911,7 +907,7 @@
 
     if (this._showInlineDiffs) {
       e.preventDefault();
-      this.$.diffCursor.moveUp();
+      this.diffCursor.moveUp();
       this._displayLine = true;
     } else {
       // Up key
@@ -930,7 +926,7 @@
     }
     e.preventDefault();
     this.classList.remove('hideComments');
-    this.$.diffCursor.createCommentInPlace();
+    this.diffCursor.createCommentInPlace();
   }
 
   _handleOpenLastFile(e: CustomKeyboardEvent) {
@@ -978,9 +974,9 @@
 
     e.preventDefault();
     if (isShiftPressed(e)) {
-      this.$.diffCursor.moveToNextCommentThread();
+      this.diffCursor.moveToNextCommentThread();
     } else {
-      this.$.diffCursor.moveToNextChunk();
+      this.diffCursor.moveToNextChunk();
     }
   }
 
@@ -995,9 +991,9 @@
 
     e.preventDefault();
     if (isShiftPressed(e)) {
-      this.$.diffCursor.moveToPreviousCommentThread();
+      this.diffCursor.moveToPreviousCommentThread();
     } else {
-      this.$.diffCursor.moveToPreviousChunk();
+      this.diffCursor.moveToPreviousChunk();
     }
   }
 
@@ -1033,7 +1029,7 @@
   }
 
   _openCursorFile() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
+    const diff = this.diffCursor.getTargetDiffElement();
     if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
     }
@@ -1240,12 +1236,7 @@
 
   _updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
-    this.$.diffCursor.splice(
-      'diffs',
-      0,
-      this.$.diffCursor.diffs.length,
-      ...this.diffs
-    );
+    this.diffCursor.replaceDiffs(this.diffs);
   }
 
   _filesChanged() {
@@ -1382,7 +1373,7 @@
     }
 
     this._updateDiffCursor();
-    this.$.diffCursor.reInitAndUpdateStops();
+    this.diffCursor.reInitAndUpdateStops();
   }
 
   private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
@@ -1432,7 +1423,9 @@
         console.info('Expanding diff', iter, 'of', initialCount, ':', path);
         const diffElem = this._findDiffByPath(path, diffElements);
         if (!diffElem) {
-          console.warn(`Did not find <gr-diff-host> element for ${path}`);
+          this.reporting.error(
+            new Error(`Did not find <gr-diff-host> element for ${path}`)
+          );
           return Promise.resolve();
         }
         if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
@@ -1470,7 +1463,7 @@
        * prevented the issue of scrolling to top when we expand the second
        * file individually.
        */
-        this.$.diffCursor.reInitAndUpdateStops();
+        this.diffCursor.reInitAndUpdateStops();
       })
     );
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index 40bd5bc..8cd35a5 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -735,5 +735,4 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index dcc2e46..4d793b9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -543,7 +543,7 @@
         assert.equal(element.fileCursor.index, 0);
         assert.equal(element.selectedIndex, 0);
 
-        const createCommentInPlaceStub = sinon.stub(element.$.diffCursor,
+        const createCommentInPlaceStub = sinon.stub(element.diffCursor,
             'createCommentInPlace');
         MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
         assert.isTrue(createCommentInPlaceStub.called);
@@ -664,8 +664,8 @@
       });
 
       test('shift+left/shift+right', () => {
-        const moveLeftStub = sinon.stub(element.$.diffCursor, 'moveLeft');
-        const moveRightStub = sinon.stub(element.$.diffCursor, 'moveRight');
+        const moveLeftStub = sinon.stub(element.diffCursor, 'moveLeft');
+        const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
 
         let noDiffsExpanded = true;
         sinon.stub(element, '_noDiffsExpanded')
@@ -913,9 +913,9 @@
 
     test('expandAllDiffs and collapseAllDiffs', () => {
       const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
-      const cursorUpdateStub = sinon.stub(element.$.diffCursor,
+      const cursorUpdateStub = sinon.stub(element.diffCursor,
           'handleDiffUpdate');
-      const reInitStub = sinon.stub(element.$.diffCursor,
+      const reInitStub = sinon.stub(element.diffCursor,
           'reInitAndUpdateStops');
 
       const path = 'path/to/my/file.txt';
@@ -1398,7 +1398,7 @@
       }
 
       element._updateDiffCursor();
-      element.$.diffCursor.handleDiffUpdate();
+      element.diffCursor.handleDiffUpdate();
       return diffs;
     }
 
@@ -1484,10 +1484,11 @@
 
       // The file cursor is now at 1.
       assert.equal(element.fileCursor.index, 1);
+
       MockInteractions.keyUpOn(element, 73, null, 'i');
       flush();
-
       diffs = await renderAndGetNewDiffs(1);
+
       // Two diffs should be rendered.
       assert.equal(diffs.length, 2);
       const diffStopsFirst = diffs[0].getCursorStops();
@@ -1537,9 +1538,9 @@
       setup(() => {
         sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
         nKeySpy = sinon.spy(element, '_handleNextChunk');
-        nextCommentStub = sinon.stub(element.$.diffCursor,
+        nextCommentStub = sinon.stub(element.diffCursor,
             'moveToNextCommentThread');
-        nextChunkStub = sinon.stub(element.$.diffCursor,
+        nextChunkStub = sinon.stub(element.diffCursor,
             'moveToNextChunk');
         fileRows =
             element.root.querySelectorAll('.row:not(.header-row)');
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 4a41316..57c1a30 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -20,7 +20,7 @@
   <style include="gr-voting-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <style include="shared-styles">
+  <style>
     :host {
       display: block;
       position: relative;
@@ -148,6 +148,7 @@
       vertical-align: top;
     }
     .score {
+      box-sizing: border-box;
       border-radius: var(--border-radius);
       color: var(--vote-text-color);
       display: inline-block;
@@ -199,6 +200,10 @@
         font-weight: var(--font-weight-bold);
       }
     }
+    iron-icon {
+      --iron-icon-height: 20px;
+      --iron-icon-width: 20px;
+    }
     @media screen and (max-width: 50em) {
       .expanded .content {
         padding-left: 0;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 6be0c07..4e2aaf1 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -270,7 +270,9 @@
       | undefined;
 
     if (!el && this._showAllActivity) {
-      console.warn(`Failed to scroll to message: ${messageID}`);
+      this.reporting.error(
+        new Error(`Failed to scroll to message: ${messageID}`)
+      );
       return;
     }
     if (!el) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 49c2486..921c45c 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -54,6 +54,7 @@
         a {
           display: block;
         }
+        :host,
         .changeContainer,
         a {
           max-width: 100%;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 5595d15..360d7de 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -40,6 +40,7 @@
   getRevisionKey,
   isChangeInfo,
 } from '../../../utils/change-util';
+import {Interaction} from '../../../constants/reporting';
 
 /** What is the maximum number of shown changes in collapsed list? */
 const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
@@ -101,15 +102,37 @@
         section {
           margin-bottom: var(--spacing-l);
         }
-        gr-related-change {
+        .relatedChangeLine {
           display: flex;
+          visibility: visible;
+          height: auto;
         }
-        .marker {
-          position: absolute;
-          margin-left: calc(-1 * var(--spacing-s));
+        .marker.arrow {
+          visibility: hidden;
+          min-width: 20px;
         }
-        .arrowToCurrentChange {
-          position: absolute;
+        .marker.arrowToCurrentChange {
+          min-width: 20px;
+          text-align: center;
+        }
+        .marker.space {
+          height: 1px;
+          min-width: 20px;
+        }
+        gr-related-collapse[collapsed] .marker.arrow {
+          visibility: visible;
+          min-width: auto;
+        }
+        gr-related-collapse[collapsed] .relatedChangeLine.show-when-collapsed {
+          visibility: visible;
+          height: auto;
+        }
+        /* keep width, so width of section and position of show all button
+         * are set according to width of all (even hidden) elements
+         */
+        gr-related-collapse[collapsed] .relatedChangeLine {
+          visibility: hidden;
+          height: 0px;
         }
       `,
     ];
@@ -151,13 +174,16 @@
       >
         ${this.relatedChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
+                  .showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 relatedChangesMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .connectedRevisions="${connectedRevisions}"
                 .href="${change?._change_number
@@ -169,7 +195,8 @@
                   : ''}"
                 .showChangeStatus=${true}
                 >${change.commit.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -202,14 +229,17 @@
       >
         ${submittedTogetherChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: submittedTogetherMarkersPredicate(
+                  index
+                ).showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: submittedTogetherMarkersPredicate(
-                    index
-                  ).showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
@@ -218,7 +248,8 @@
                 .showSubmittableCheck=${true}
                 >${change.project}: ${change.branch}:
                 ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
       <div class="note" ?hidden=${!countNonVisibleChanges}>
@@ -246,13 +277,16 @@
       >
         ${this.sameTopicChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: sameTopicMarkersPredicate(index)
+                  .showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: sameTopicMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
@@ -260,7 +294,8 @@
                 )}"
                 >${change.project}: ${change.branch}:
                 ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -285,20 +320,24 @@
       >
         ${this.conflictingChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: mergeConflictsMarkersPredicate(index)
+                  .showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 mergeConflictsMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: mergeConflictsMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
                 >${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -323,20 +362,24 @@
       >
         ${this.cherryPickChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']: cherryPicksMarkersPredicate(index)
+                  .showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 cherryPicksMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: cherryPicksMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
                 >${change.branch}: ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -465,7 +508,7 @@
     if (changeMarkers.showCurrentChangeArrow) {
       return html`<span
         role="img"
-        class="arrowToCurrentChange"
+        class="marker arrowToCurrentChange"
         aria-label="Arrow marking current change"
         >âž”</span
       >`;
@@ -473,7 +516,7 @@
     if (changeMarkers.showTopArrow) {
       return html`<span
         role="img"
-        class="marker"
+        class="marker arrow"
         aria-label="Arrow marking change has collapsed ancestors"
         ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
       ></span> `;
@@ -481,12 +524,12 @@
     if (changeMarkers.showBottomArrow) {
       return html`<span
         role="img"
-        class="marker"
+        class="marker arrow"
         aria-label="Arrow marking change has collapsed descendants"
         ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
       ></span> `;
     }
-    return nothing;
+    return html`<span class="marker space"></span>`;
   }
 
   reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
@@ -628,9 +671,12 @@
   @property()
   title = '';
 
-  @property()
+  @property({type: Boolean})
   showAll = false;
 
+  @property({type: Boolean, reflect: true})
+  collapsed = true;
+
   @property()
   length = 0;
 
@@ -655,36 +701,8 @@
         gr-button {
           display: flex;
         }
-        /* This is a hacky solution from old gr-related-change-list
-         * TODO(milutin): find layout without needing it
-         */
-        h4:before,
-        gr-button:before,
-        ::slotted(gr-related-change):before {
-          content: ' ';
-          flex-shrink: 0;
-          width: 1.2em;
-        }
-        .collapsed ::slotted(gr-related-change.show-when-collapsed) {
-          visibility: visible;
-          height: auto;
-        }
-        .collapsed ::slotted(.marker) {
-          display: block;
-        }
-        .show-all ::slotted(.marker) {
-          display: none;
-        }
-        /* keep width, so width of section and position of show all button
-         * are set according to width of all (even hidden) elements
-         */
-        .collapsed ::slotted(gr-related-change) {
-          visibility: hidden;
-          height: 0px;
-        }
-        ::slotted(gr-related-change) {
-          visibility: visible;
-          height: auto;
+        h4 {
+          margin-left: 20px;
         }
         gr-button iron-icon {
           color: inherit;
@@ -707,11 +725,7 @@
     const title = html`<h4 class="title">${this.title}</h4>`;
 
     const collapsible = this.length > this.numChangesWhenCollapsed;
-    const items = html` <div
-      class="${!this.showAll && collapsible ? 'collapsed' : 'show-all'}"
-    >
-      <slot></slot>
-    </div>`;
+    this.collapsed = !this.showAll && collapsible;
 
     let button: TemplateResult | typeof nothing = nothing;
     if (collapsible) {
@@ -727,13 +741,13 @@
     }
 
     return html`<div class="container">${title}${button}</div>
-      ${items}`;
+      <div><slot></slot></div>`;
   }
 
   private toggle(e: MouseEvent) {
     e.stopPropagation();
     this.showAll = !this.showAll;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: this.title,
       toState: this.showAll ? 'Show all' : 'Show less',
     });
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 5504877..939b23c 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
@@ -113,7 +113,7 @@
 import {ErrorCallback} from '../../../api/rest';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {StorageLocation} from '../../../services/storage/gr-storage';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -758,7 +758,7 @@
     if (changeReviewers) {
       for (const key of Object.keys(changeReviewers)) {
         if (key !== 'REVIEWER' && key !== 'CC') {
-          console.warn('unexpected reviewer state:', key);
+          this.reporting.error(new Error(`Unexpected reviewer state: ${key}`));
           continue;
         }
         if (!changeReviewers[key]) continue;
@@ -820,12 +820,12 @@
 
     if (this._newAttentionSet.has(id)) {
       this._newAttentionSet.delete(id);
-      this.reporting.reportInteraction('attention-set-chip', {
+      this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `REMOVE${self}${role}`,
       });
     } else {
       this._newAttentionSet.add(id);
-      this.reporting.reportInteraction('attention-set-chip', {
+      this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `ADD${self}${role}`,
       });
     }
@@ -1085,9 +1085,8 @@
       } else if (isReviewerGroupSuggestion(suggestion)) {
         entry = suggestion.group;
       } else {
-        console.warn(
-          'received suggestion that was neither account nor group:',
-          suggestion
+        this.reporting.error(
+          new Error(`Suggestion is neither account nor group: ${suggestion}`)
         );
         return false;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 17e5e26..c3c1d54 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -391,8 +391,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
+                  selection-chip-style
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
               </template>
@@ -462,8 +462,8 @@
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
               selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              deselected="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
               hide-hovercard=""
+              selection-chip-style
               on-click="_handleAttentionClick"
             >
             </gr-account-label>
@@ -477,8 +477,8 @@
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 hide-hovercard=""
+                selection-chip-style
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -496,9 +496,9 @@
               <gr-account-label
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]
                 hide-hovercard=""
+                selection-chip-style
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -518,8 +518,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
+                  selection-chip-style
                   on-click="_handleAttentionClick"
                 >
                 </gr-account-label>
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 2f4adf5..8283be79 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
@@ -112,7 +112,7 @@
   }
 
   _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments.' : 'No unresolved comments';
+    return !threads.length ? 'No comments' : 'No unresolved comments';
   }
 
   _showPartyPopper(threads: CommentThread[]) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index 1fe00ff..3d6cfd4 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -611,7 +611,7 @@
     test('default empty message should show', () => {
       assert.isTrue(
           element.shadowRoot.querySelector('#threads').textContent.trim()
-              .includes('No comments.'));
+              .includes('No comments'));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 4b7aec5..7796a6e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -17,6 +17,7 @@
 import {html} from 'lit-html';
 import {classMap} from 'lit-html/directives/class-map';
 import {repeat} from 'lit-html/directives/repeat';
+import {ifDefined} from 'lit-html/directives/if-defined';
 import {
   css,
   customElement,
@@ -39,22 +40,23 @@
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
-  allActions$,
-  allLinks$,
   CheckRun,
-  checksPatchsetNumber$,
+  checksSelectedPatchsetNumber$,
   RunResult,
-  someProvidersAreLoading$,
+  someProvidersAreLoadingSelected$,
+  topLevelActionsSelected$,
+  topLevelLinksSelected$,
 } from '../../services/checks/checks-model';
 import {
   allResults,
   fireActionTriggered,
+  firstPrimaryLink,
   hasCompletedWithoutResults,
   iconForCategory,
   iconForLink,
-  otherLinks,
-  primaryLink,
+  otherPrimaryLinks,
   primaryRunAction,
+  secondaryLinks,
   tooltipForLink,
 } from '../../services/checks/checks-util';
 import {assertIsDefined, check} from '../../utils/common-util';
@@ -77,6 +79,7 @@
   getRepresentativeValue,
   valueString,
 } from '../../utils/label-util';
+import {GerritNav} from '../core/gr-navigation/gr-navigation';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
@@ -113,7 +116,7 @@
         gr-result-expanded {
           cursor: default;
         }
-        tr {
+        tr.container {
           border-top: 1px solid var(--border-color);
         }
         iron-icon.link {
@@ -169,27 +172,30 @@
           overflow: hidden;
           text-overflow: ellipsis;
         }
-        tr:hover {
+        tr.container:hover {
           background: var(--hover-background-color);
         }
-        tr td .summary-cell .links,
-        tr td .summary-cell .actions,
-        tr.collapsed:hover td .summary-cell .links,
-        tr.collapsed:hover td .summary-cell .actions,
+        tr.container td .summary-cell .links,
+        tr.container td .summary-cell .actions,
+        tr.container.collapsed:hover td .summary-cell .links,
+        tr.container.collapsed:hover td .summary-cell .actions,
         :host(.dropdown-open) tr td .summary-cell .links,
         :host(.dropdown-open) tr td .summary-cell .actions {
           display: inline-block;
           margin-left: var(--spacing-s);
         }
-        tr.collapsed td .summary-cell .message {
+        tr.container.collapsed td .summary-cell .message {
           color: var(--deemphasized-text-color);
         }
-        tr.collapsed td .summary-cell .links,
-        tr.collapsed td .summary-cell .actions {
+        tr.container.collapsed td .summary-cell .links,
+        tr.container.collapsed td .summary-cell .actions {
           display: none;
         }
-        tr.collapsed:hover .summary-cell .hoverHide.tags,
-        tr.collapsed:hover .summary-cell .hoverHide.label {
+        tr.container.collapsed:hover .summary-cell .hoverHide.tags,
+        tr.container.collapsed:hover .summary-cell .hoverHide.label {
+          display: none;
+        }
+        tr.detailsRow.collapsed {
           display: none;
         }
         td .summary-cell .tags .tag {
@@ -294,19 +300,21 @@
     return html`
       <tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
         <td class="iconCol" @click="${this.toggleExpanded}">
-          <div>${this.renderIcon()}</div>
+          <div>${this.runningIcon()}</div>
         </td>
         <td class="nameCol" @click="${this.toggleExpanded}">
           <div class="flex">
             <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
-            <div class="name">${this.result.checkName}</div>
+            <div class="name">
+              ${this.result.checkName} ${this.renderStatus()}
+            </div>
             <div class="space"></div>
             ${this.renderPrimaryRunAction()}
           </div>
         </td>
         <td class="summaryCol">
           <div class="summary-cell">
-            ${this.renderLink(primaryLink(this.result))}
+            ${this.renderLink(firstPrimaryLink(this.result))}
             ${this.renderSummary(this.result.summary)}
             <div class="message" @click="${this.toggleExpanded}">
               ${this.isExpanded ? '' : this.result.message}
@@ -316,7 +324,6 @@
             </div>
             ${this.renderLabel()} ${this.renderLinks()} ${this.renderActions()}
           </div>
-          ${this.renderExpanded()}
         </td>
         <td class="expanderCol" @click="${this.toggleExpanded}">
           <div
@@ -338,6 +345,10 @@
           </div>
         </td>
       </tr>
+      <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
+        <td></td>
+        <td colspan="3">${this.renderExpanded()}</td>
+      </tr>
     `;
   }
 
@@ -376,7 +387,12 @@
     `;
   }
 
-  renderIcon() {
+  private renderStatus() {
+    if (this.result?.status !== RunStatus.RUNNING) return;
+    return html`<span>(Running)</span>`;
+  }
+
+  private runningIcon() {
     if (this.result?.status !== RunStatus.RUNNING) return;
     return html`<iron-icon icon="gr-icons:timelapse"></iron-icon>`;
   }
@@ -397,12 +413,23 @@
   }
 
   renderLinks() {
-    const links = otherLinks(this.result);
+    const links = otherPrimaryLinks(this.result)
+      // Showing the same icons twice without text is super confusing.
+      .filter(
+        (link: Link, index: number, array: Link[]) =>
+          array.findIndex(other => link.icon === other.icon) === index
+      )
+      // 4 is enough for the summary row. All are shown in expanded state.
+      .slice(0, 4);
     if (links.length === 0) return;
-    return html`<div class="links">${links.map(this.renderLink)}</div>`;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
   }
 
   renderLink(link?: Link) {
+    // The expanded state renders all links in more detail. Hide in summary.
+    if (this.isExpanded) return;
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
     return html`<a href="${link.url}" target="_blank"
@@ -485,10 +512,23 @@
   @property()
   repoConfig?: ConfigInfo;
 
+  private changeService = appContext.changeService;
+
   static get styles() {
     return [
       sharedStyles,
       css`
+        .links {
+          white-space: normal;
+          padding: var(--spacing-s) 0;
+        }
+        .links a {
+          display: inline-block;
+          margin-right: var(--spacing-xl);
+        }
+        .links a iron-icon {
+          margin-right: var(--spacing-xs);
+        }
         .message {
           padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0;
         }
@@ -504,6 +544,8 @@
   render() {
     if (!this.result) return '';
     return html`
+      ${this.renderFirstPrimaryLink()} ${this.renderOtherPrimaryLinks()}
+      ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
       <gr-endpoint-decorator name="check-result-expanded">
         <gr-endpoint-param
           name="run"
@@ -522,6 +564,67 @@
       </gr-endpoint-decorator>
     `;
   }
+
+  private renderFirstPrimaryLink() {
+    const link = firstPrimaryLink(this.result);
+    if (!link) return;
+    return html`<div class="links">${this.renderLink(link)}</div>`;
+  }
+
+  private renderOtherPrimaryLinks() {
+    const links = otherPrimaryLinks(this.result);
+    if (links.length === 0) return;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
+  }
+
+  private renderSecondaryLinks() {
+    const links = secondaryLinks(this.result);
+    if (links.length === 0) return;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
+  }
+
+  private renderCodePointers() {
+    const pointers = this.result?.codePointers ?? [];
+    if (pointers.length === 0) return;
+    const links = pointers.map(pointer => {
+      let rangeText = '';
+      const start = pointer?.range?.start_line;
+      const end = pointer?.range?.end_line;
+      if (start) rangeText += `#${start}`;
+      if (end && start !== end) rangeText += `-${end}`;
+      const change = this.changeService.getChange();
+      assertIsDefined(change);
+      const path = pointer.path;
+      const patchset = this.result?.patchset as PatchSetNumber | undefined;
+      const line = pointer?.range?.start_line;
+      return {
+        icon: LinkIcon.CODE,
+        tooltip: `${path}${rangeText}`,
+        url: GerritNav.getUrlForDiff(change, path, patchset, undefined, line),
+        primary: true,
+      };
+    });
+    return links.map(
+      link => html`<div class="links">${this.renderLink(link, false)}</div>`
+    );
+  }
+
+  private renderLink(link?: Link, targetBlank = true) {
+    if (!link) return;
+    const text = link.tooltip ?? tooltipForLink(link.icon);
+    const target = targetBlank ? '_blank' : undefined;
+    return html`<a href="${link.url}" target="${ifDefined(target)}">
+      <iron-icon
+        class="link"
+        icon="gr-icons:${iconForLink(link.icon)}"
+      ></iron-icon
+      ><span>${text}</span>
+    </a>`;
+  }
 }
 
 const SHOW_ALL_THRESHOLDS: Map<Category, number> = new Map();
@@ -611,11 +714,11 @@
 
   constructor() {
     super();
-    this.subscribe('actions', allActions$);
-    this.subscribe('links', allLinks$);
-    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+    this.subscribe('actions', topLevelActionsSelected$);
+    this.subscribe('links', topLevelLinksSelected$);
+    this.subscribe('checksPatchsetNumber', checksSelectedPatchsetNumber$);
     this.subscribe('latestPatchsetNumber', latestPatchNum$);
-    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoadingSelected$);
   }
 
   static get styles() {
@@ -634,6 +737,9 @@
             var(--spacing-xl);
           border-bottom: 1px solid var(--border-color);
         }
+        .header.notLatest {
+          background-color: var(--emphasis-color);
+        }
         .headerTopRow,
         .headerBottomRow {
           max-width: 1600px;
@@ -665,10 +771,20 @@
         .headerBottomRow {
           margin-top: var(--spacing-s);
         }
+        .headerTopRow .right,
         .headerBottomRow .right {
           display: flex;
           align-items: center;
         }
+        .headerTopRow .right .goToLatest {
+          display: none;
+        }
+        .notLatest .headerTopRow .right .goToLatest {
+          display: block;
+        }
+        .headerTopRow .right .goToLatest gr-button {
+          margin-right: var(--spacing-m);
+        }
         .headerBottomRow iron-icon {
           color: var(--link-color);
         }
@@ -812,16 +928,34 @@
   private scrollElIntoView(selector: string) {
     this.updateComplete.then(() => {
       let el = this.shadowRoot?.querySelector(selector);
-      // <gr-result-row> has display:contents and cannot be scrolled into view
-      // itself. Thus we are preferring to scroll the first child into view.
-      el = el?.shadowRoot?.firstElementChild ?? el;
-      el?.scrollIntoView({block: 'center'});
+      // el might be a <gr-result-row> with an empty shadowRoot. Let's wait a
+      // moment before trying to find a child element in it.
+      setTimeout(() => {
+        // <gr-result-row> has display:contents and cannot be scrolled into view
+        // itself. Thus we are preferring to scroll the first child into view.
+        el = el?.shadowRoot?.firstElementChild ?? el;
+        el?.scrollIntoView({block: 'center'});
+      }, 0);
     });
   }
 
   render() {
-    return html`
-      <div class="header">
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    const style = html`<style>
+      .headerTopRow .right .goToLatest gr-button {
+        --gr-button: {
+          padding: var(--spacing-s) var(--spacing-m);
+          text-transform: none;
+        }
+      }
+    </style>`;
+    const headerClasses = classMap({
+      header: true,
+      notLatest: !!this.checksPatchsetNumber,
+    });
+    return html`${style}
+      <div class="${headerClasses}">
         <div class="headerTopRow">
           <div class="left">
             <h2 class="heading-2">Results</h2>
@@ -831,8 +965,13 @@
             </div>
           </div>
           <div class="right">
+            <div class="goToLatest">
+              <gr-button @click="${this.goToLatestPatchset}" link
+                >Go to latest patchset</gr-button
+              >
+            </div>
             <gr-dropdown-list
-              value="${this.checksPatchsetNumber}"
+              value="${this.checksPatchsetNumber ?? this.latestPatchsetNumber}"
               .items="${this.createPatchsetDropdownItems()}"
               @value-change="${this.onPatchsetSelected}"
             ></gr-dropdown-list>
@@ -852,14 +991,20 @@
         ${this.renderSection(Category.WARNING)}
         ${this.renderSection(Category.INFO)}
         ${this.renderSection(Category.SUCCESS)}
-      </div>
-    `;
+      </div>`;
   }
 
   private renderLinks() {
     const links = this.links ?? [];
     if (links.length === 0) return;
-    const primaryLinks = links.filter(a => a.primary).slice(0, 4);
+    const primaryLinks = links
+      .filter(a => a.primary)
+      // Showing the same icons twice without text is super confusing.
+      .filter(
+        (link: Link, index: number, array: Link[]) =>
+          array.findIndex(other => link.icon === other.icon) === index
+      )
+      .slice(0, 4);
     const overflowLinks = links.filter(a => !primaryLinks.includes(a));
     return html`
       ${primaryLinks.map(this.renderLink)}
@@ -957,6 +1102,10 @@
     this.checksService.setPatchset(patchset as PatchSetNumber);
   }
 
+  private goToLatestPatchset() {
+    this.checksService.setPatchset(undefined);
+  }
+
   private createPatchsetDropdownItems() {
     if (!this.latestPatchsetNumber) return [];
     return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index edcc856..093eecb 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -32,15 +32,19 @@
 import {
   AttemptDetail,
   compareByWorstCategory,
+  fireActionTriggered,
   iconForCategory,
   iconForRun,
+  PRIMARY_STATUS_ACTIONS,
   primaryRunAction,
   worstCategory,
 } from '../../services/checks/checks-util';
 import {
+  allRunsSelectedPatchset$,
   CheckRun,
-  allRuns$,
+  ChecksPatchset,
   fakeActions,
+  fakeLinks,
   fakeRun0,
   fakeRun1,
   fakeRun2,
@@ -50,7 +54,6 @@
   fakeRun4_3,
   fakeRun4_4,
   updateStateSetResults,
-  fakeLinks,
 } from '../../services/checks/checks-model';
 import {assertIsDefined} from '../../utils/common-util';
 import {whenVisible} from '../../utils/dom-util';
@@ -134,10 +137,6 @@
         div.chip.selected iron-icon.filter {
           color: var(--selected-foreground);
         }
-        .chip.selected gr-checks-action,
-        .chip.deselected gr-checks-action {
-          display: none;
-        }
         gr-checks-action {
           /* The button should fit into the 20px line-height. The negative
              margin provides the extra space needed for the vertical padding.
@@ -314,6 +313,9 @@
   @property()
   runs: CheckRun[] = [];
 
+  @property({type: Boolean, reflect: true})
+  collapsed = false;
+
   @property()
   selectedRuns: string[] = [];
 
@@ -333,7 +335,7 @@
 
   constructor() {
     super();
-    this.subscribe('runs', allRuns$);
+    this.subscribe('runs', allRunsSelectedPatchset$);
   }
 
   static get styles() {
@@ -342,12 +344,30 @@
       css`
         :host {
           display: block;
+        }
+        :host(:not([collapsed])) {
+          min-width: 320px;
           padding: var(--spacing-l) var(--spacing-xl) var(--spacing-xl)
             var(--spacing-xl);
         }
+        :host([collapsed]) {
+          padding: var(--spacing-l) 0;
+        }
         .title {
           display: flex;
-          justify-content: space-between;
+        }
+        .title .flex-space {
+          flex-grow: 1;
+        }
+        .title gr-button {
+          --padding: var(--spacing-s) var(--spacing-m);
+          white-space: nowrap;
+        }
+        .title gr-button.expandButton {
+          --padding: var(--spacing-xs) var(--spacing-s);
+        }
+        :host(:not([collapsed])) .expandButton {
+          margin-right: calc(0px - var(--spacing-m));
         }
         .expandIcon {
           width: var(--line-height-h3);
@@ -408,17 +428,14 @@
   }
 
   render() {
+    if (this.collapsed) {
+      return html`${this.renderCollapseButton()}`;
+    }
     return html`
       <h2 class="title">
         <div class="heading-2">Runs</div>
-        <div class="font-normal">
-          <gr-button
-            ?hidden="${this.selectedRuns.length < 2}"
-            link
-            @click="${() => fireRunSelectionReset(this)}"
-            >Unselect All</gr-button
-          >
-        </div>
+        <div class="flex-space"></div>
+        ${this.renderTitleButtons()} ${this.renderCollapseButton()}
       </h2>
       <input
         id="filterInput"
@@ -427,36 +444,102 @@
         ?hidden="${!this.showFilter()}"
         @input="${this.onInput}"
       />
-      ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNING)}
+      ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()}
     `;
   }
 
+  private renderTitleButtons() {
+    if (this.selectedRuns.length < 2) return;
+    const actions = this.selectedRuns.map(selected => {
+      const run = this.runs.find(
+        run => run.isLatestAttempt && run.checkName === selected
+      );
+      return primaryRunAction(run);
+    });
+    const runButtonDisabled = !actions.every(
+      action =>
+        action?.name === PRIMARY_STATUS_ACTIONS.RUN ||
+        action?.name === PRIMARY_STATUS_ACTIONS.RERUN
+    );
+    return html`
+      <gr-button
+        class="font-normal"
+        link
+        @click="${() => fireRunSelectionReset(this)}"
+        >Unselect All</gr-button
+      >
+      <gr-button
+        class="font-normal"
+        link
+        title="${runButtonDisabled
+          ? 'Disabled. Unselect checks without a "Run" action to enable the button.'
+          : ''}"
+        has-tooltip="${runButtonDisabled}"
+        ?disabled="${runButtonDisabled}"
+        @click="${() => {
+          actions.forEach(action => fireActionTriggered(this, action));
+        }}"
+        >Run Selected</gr-button
+      >
+    `;
+  }
+
+  private renderCollapseButton() {
+    return html`
+      <gr-button
+        link
+        class="expandButton"
+        role="switch"
+        ?aria-checked="${this.collapsed}"
+        aria-label="${this.collapsed
+          ? 'Expand runs panel'
+          : 'Collapse runs panel'}"
+        has-tooltip="true"
+        title="${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}"
+        @click="${() => (this.collapsed = !this.collapsed)}"
+        ><iron-icon
+          class="expandIcon"
+          icon="${this.collapsed
+            ? 'gr-icons:chevron-right'
+            : 'gr-icons:chevron-left'}"
+        ></iron-icon>
+      </gr-button>
+    `;
+  }
+
   onInput() {
     assertIsDefined(this.filterInput, 'filter <input> element');
     this.filterRegExp = new RegExp(this.filterInput.value, 'i');
   }
 
   none() {
-    updateStateSetResults('f0', [], []);
-    updateStateSetResults('f1', []);
-    updateStateSetResults('f2', []);
-    updateStateSetResults('f3', []);
-    updateStateSetResults('f4', []);
+    updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
   }
 
   all() {
-    updateStateSetResults('f0', [fakeRun0], fakeActions, fakeLinks);
-    updateStateSetResults('f1', [fakeRun1]);
-    updateStateSetResults('f2', [fakeRun2]);
-    updateStateSetResults('f3', [fakeRun3]);
-    updateStateSetResults('f4', [
-      fakeRun4_1,
-      fakeRun4_2,
-      fakeRun4_3,
-      fakeRun4_4,
-    ]);
+    updateStateSetResults(
+      'f0',
+      [fakeRun0],
+      fakeActions,
+      fakeLinks,
+      ChecksPatchset.LATEST
+    );
+    updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults(
+      'f4',
+      [fakeRun4_1, fakeRun4_2, fakeRun4_3, fakeRun4_4],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
   }
 
   toggle(
@@ -466,7 +549,13 @@
     links: Link[] = []
   ) {
     const newRuns = this.runs.includes(runs[0]) ? [] : runs;
-    updateStateSetResults(plugin, newRuns, actions, links);
+    updateStateSetResults(
+      plugin,
+      newRuns,
+      actions,
+      links,
+      ChecksPatchset.LATEST
+    );
   }
 
   renderSection(status: RunStatus) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index b28596a..e7a7659 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -21,13 +21,13 @@
 import {
   CheckResult,
   CheckRun,
-  allResults$,
-  allRuns$,
-  checksPatchsetNumber$,
+  allResultsSelected$,
+  checksSelectedPatchsetNumber$,
+  allRunsSelectedPatchset$,
 } from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
-import {changeNum$} from '../../services/change/change-model';
+import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {ActionTriggeredEvent} from '../../services/checks/checks-util';
 import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
@@ -56,6 +56,9 @@
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
 
   @property()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
   changeNum: NumericChangeId | undefined = undefined;
 
   @state()
@@ -72,9 +75,10 @@
 
   constructor() {
     super();
-    this.subscribe('runs', allRuns$);
-    this.subscribe('results', allResults$);
-    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+    this.subscribe('runs', allRunsSelectedPatchset$);
+    this.subscribe('results', allResultsSelected$);
+    this.subscribe('checksPatchsetNumber', checksSelectedPatchsetNumber$);
+    this.subscribe('latestPatchsetNumber', latestPatchNum$);
     this.subscribe('changeNum', changeNum$);
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
@@ -91,7 +95,6 @@
         display: flex;
       }
       .runs {
-        min-width: 300px;
         min-height: 400px;
         border-right: 1px solid var(--border-color);
       }
@@ -136,10 +139,11 @@
 
   handleActionTriggered(action: Action, run?: CheckRun) {
     if (!this.changeNum) return;
-    if (!this.checksPatchsetNumber) return;
+    const patchSet = this.checksPatchsetNumber ?? this.latestPatchsetNumber;
+    if (!patchSet) return;
     const promise = action.callback(
       this.changeNum,
-      this.checksPatchsetNumber,
+      patchSet,
       run?.attempt,
       run?.externalId,
       run?.checkName,
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
index 0fa2f1e..97f4a89 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
@@ -45,7 +45,7 @@
       account="[[account]]"
       hidden$="[[!_hasAvatars]]"
       hidden=""
-      image-size="56"
+      imageSize="56"
       aria-label="Account avatar"
     ></gr-avatar>
   </gr-dropdown>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 4985c1b..8f9dfe2 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -217,6 +217,8 @@
             url,
             trace,
           });
+        } else if (response.status === 429) {
+          this._showQuotaExceeded({status, statusText});
         } else {
           this._showErrorDialog(
             this._constructServerErrorMsg({
@@ -229,7 +231,7 @@
           );
         }
       }
-      console.info(`server error: ${errorText}`);
+      this.reporting.error(new Error(`Server error: ${errorText}`));
     });
   };
 
@@ -260,6 +262,19 @@
     });
   }
 
+  _showQuotaExceeded({status, statusText}: ErrorMsg) {
+    const tip = 'Try again later';
+    const errorText = 'Too many requests from this client';
+    this._showErrorDialog(
+      this._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        tip,
+      })
+    );
+  }
+
   _constructServerErrorMsg({
     errorText,
     status,
@@ -304,7 +319,7 @@
 
   private readonly handleNetworkError = (e: NetworkErrorEvent) => {
     this._showAlert('Server unavailable');
-    console.error(e.detail.error.message);
+    this.reporting.error(new Error(`network error: ${e.detail.error.message}`));
   };
 
   // TODO(dhruvsr): allow less priority alerts to override high priority alerts
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
index 91f119e..fe4d9da 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
@@ -207,7 +207,6 @@
     });
 
     test('show network error', done => {
-      const consoleErrorStub = sinon.stub(console, 'error');
       const showAlertStub = sinon.stub(element, '_showAlert');
       element.dispatchEvent(
           new CustomEvent('network-error', {
@@ -218,8 +217,6 @@
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server unavailable'));
-        assert.isTrue(consoleErrorStub.calledOnce);
-        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
         done();
       });
     });
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
deleted file mode 100644
index ccba289..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-import {css} from 'lit-element';
-
-export const cssTemplate = css`
-  .key {
-    background-color: var(--chip-background-color);
-    color: var(--primary-text-color);
-    border: 1px solid var(--border-color);
-    border-radius: var(--border-radius);
-    display: inline-block;
-    font-weight: var(--font-weight-bold);
-    padding: var(--spacing-xxs) var(--spacing-m);
-    text-align: center;
-  }
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
index 091684f..657d7cc 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -16,9 +16,7 @@
  */
 import {html} from 'lit-html';
 import {GrLitElement} from '../../lit/gr-lit-element';
-import {customElement, property} from 'lit-element';
-import {cssTemplate} from './gr-key-binding-display.css';
-import {sharedStyles} from '../../../styles/shared-styles';
+import {css, customElement, property} from 'lit-element';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -29,7 +27,20 @@
 @customElement('gr-key-binding-display')
 export class GrKeyBindingDisplay extends GrLitElement {
   static get styles() {
-    return [sharedStyles, cssTemplate];
+    return [
+      css`
+        .key {
+          background-color: var(--chip-background-color);
+          color: var(--primary-text-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          display: inline-block;
+          font-weight: var(--font-weight-bold);
+          padding: var(--spacing-xxs) var(--spacing-m);
+          text-align: center;
+        }
+      `,
+    ];
   }
 
   render() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
index 763a524..c66ea4f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -18,6 +18,7 @@
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {queryAndAssert} from '../../../utils/common-util';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
   constructor(
@@ -32,7 +33,7 @@
     const section = this._createElement('tbody', 'binary-diff');
     const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
     const fileRow = this._createRow(line);
-    const contentTd = fileRow.querySelector('td.both.file')!;
+    const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
     contentTd.textContent = ' Difference in binary files';
     section.appendChild(fileRow);
     return section;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 96cb56a..f70974f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -17,6 +17,7 @@
 import {
   ContentLoadNeededEventDetail,
   DiffContextExpandedExternalDetail,
+  LineNumberEventDetail,
   MovedLinkClickedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
@@ -314,7 +315,9 @@
     const firstGroupIsSkipped = !!contextGroups[0].skip;
     const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
 
-    const showAbove = leftStart > 1 && !firstGroupIsSkipped;
+    const containsWholeFile = this._numLinesLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
     const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
 
     if (showAbove) {
@@ -458,6 +461,30 @@
           button.setAttribute('aria-label', `${number} added`);
         }
       }
+      button.addEventListener('mouseenter', () => {
+        button.dispatchEvent(
+          new CustomEvent<LineNumberEventDetail>('line-number-mouse-enter', {
+            detail: {
+              lineNum: number,
+              side,
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+      button.addEventListener('mouseleave', () => {
+        button.dispatchEvent(
+          new CustomEvent<LineNumberEventDetail>('line-number-mouse-leave', {
+            detail: {
+              lineNum: number,
+              side,
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
     }
 
     return td;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 69c6139..68bdaa6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -15,54 +15,93 @@
  * limitations under the License.
  */
 
-import '../../shared/gr-cursor-manager/gr-cursor-manager';
-import {
-  AbortStop,
-  CursorMoveResult,
-  GrCursorManager,
-  Stop,
-  isTargetable,
-} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {customElement, observe, property} from '@polymer/decorators';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-cursor_html';
-import {DiffViewMode} from '../../../api/diff';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {Subscription} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
+import {
+  DiffViewMode,
+  GrDiffCursor as GrDiffCursorApi,
+  LineNumberEventDetail,
+} from '../../../api/diff';
 import {ScrollMode, Side} from '../../../constants/constants';
-import {customElement, property, observe} from '@polymer/decorators';
-import {GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {PolymerDomWrapper} from '../../../types/types';
+import {toggleClass} from '../../../utils/dom-util';
+import {
+  GrCursorManager,
+  isTargetable,
+} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
-import {Subscription} from 'rxjs';
-import {toggleClass} from '../../../utils/dom-util';
 
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
 const LEFT_SIDE_CLASS = 'target-side-left';
 const RIGHT_SIDE_CLASS = 'target-side-right';
 
-export interface GrDiffCursor {
-  $: {};
+/** A subset of the GrDiff API that the cursor is using. */
+export interface GrDiffCursorable extends HTMLElement {
+  isRangeSelected(): boolean;
+  createRangeComment(): void;
+  getCursorStops(): Stop[];
+  path?: string;
 }
 
-@customElement('gr-diff-cursor')
-export class GrDiffCursor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDiffCursor implements GrDiffCursorApi {
   private preventAutoScrollOnManualScroll = false;
 
-  @property({type: String})
-  side = Side.RIGHT;
+  set side(side: Side) {
+    if (this.sideInternal === side) {
+      return;
+    }
+    if (this.sideInternal && this.diffRow) {
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRow,
+        this.sideInternal
+      );
+    }
+    this.sideInternal = side;
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
 
-  @property({type: Object, notify: true, observer: '_rowChanged'})
-  diffRow?: HTMLElement;
+  get side(): Side {
+    return this.sideInternal;
+  }
 
-  @property({type: Object})
-  diffs: GrDiff[] = [];
+  private sideInternal = Side.RIGHT;
+
+  set diffRow(diffRow: HTMLElement | undefined) {
+    if (this.diffRowInternal) {
+      this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRowInternal,
+        this.side
+      );
+    }
+    this.diffRowInternal = diffRow;
+
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
+
+  get diffRow(): HTMLElement | undefined {
+    return this.diffRowInternal;
+  }
+
+  private diffRowInternal?: HTMLElement;
+
+  private diffs: GrDiffCursorable[] = [];
 
   /**
    * If set, the cursor will attempt to move to the line number (instead of
@@ -72,62 +111,27 @@
    * to that position. This parameter should be set at most for one gr-diff
    * element in the page.
    */
-  @property({type: Number})
   initialLineNumber: number | null = null;
 
-  @property({type: Boolean})
-  _listeningForScroll = false;
-
   private cursorManager = new GrCursorManager();
 
+  private targetSubscription?: Subscription;
+
   constructor() {
-    super();
     this.cursorManager.cursorTargetClass = 'target-row';
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursorManager.focusOnMove = true;
-  }
 
-  /** @override */
-  ready() {
-    super.ready();
-    afterNextRender(this, () => {
-      /*
-      This represents the diff cursor is ready for interaction coming from
-      client components. It is more then Polymer "ready" lifecycle, as no
-      "ready" events are automatically fired by Polymer, it means
-      the cursor is completely interactable - in this case attached and
-      painted on the page. We name it "ready" instead of "rendered" as the
-      long-term goal is to make gr-diff-cursor a javascript class - not a DOM
-      element with an actual lifecycle. This will be triggered only once
-      per element.
-      */
-      this.dispatchEvent(
-        new CustomEvent('ready', {
-          composed: true,
-          bubbles: false,
-        })
-      );
-    });
-  }
-
-  private targetSubscription?: Subscription;
-
-  /** @override */
-  connectedCallback() {
-    super.connectedCallback();
-    // Catch when users are scrolling as the view loads.
     window.addEventListener('scroll', this._boundHandleWindowScroll);
     this.targetSubscription = this.cursorManager.target$.subscribe(target => {
       this.diffRow = target || undefined;
     });
   }
 
-  /** @override */
-  disconnectedCallback() {
+  dispose() {
     if (this.targetSubscription) this.targetSubscription.unsubscribe();
     window.removeEventListener('scroll', this._boundHandleWindowScroll);
     this.cursorManager.unsetCursor();
-    super.disconnectedCallback();
   }
 
   // Don't remove - used by clients embedding gr-diff outside of Gerrit.
@@ -297,10 +301,10 @@
         this.moveToFirstChunk();
       }
     }
-    this.reInit();
+    this.resetScrollMode();
   }
 
-  reInit() {
+  resetScrollMode() {
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
@@ -313,7 +317,7 @@
   };
 
   reInitAndUpdateStops() {
-    this.reInit();
+    this.resetScrollMode();
     this._updateStops();
   }
 
@@ -366,23 +370,25 @@
    * {leftSide: false, number: 123} for line 123 of the revision, or
    * {leftSide: true, number: 321} for line 321 of the base patch.
    * Returns null if an address is not available.
-   *
    */
   getAddress() {
     if (!this.diffRow) {
       return null;
     }
-
     // Get the line-number cell targeted by the cursor. If the mode is unified
     // then prefer the revision cell if available.
+    return this.getAddressFor(this.diffRow, this.side);
+  }
+
+  private getAddressFor(diffRow: HTMLElement, side: Side) {
     let cell;
     if (this._getViewMode() === DiffViewMode.UNIFIED) {
-      cell = this.diffRow.querySelector('.lineNum.right');
+      cell = diffRow.querySelector('.lineNum.right');
       if (!cell) {
-        cell = this.diffRow.querySelector('.lineNum.left');
+        cell = diffRow.querySelector('.lineNum.left');
       }
     } else {
-      cell = this.diffRow.querySelector('.lineNum.' + this.side);
+      cell = diffRow.querySelector('.lineNum.' + side);
     }
     if (!cell) {
       return null;
@@ -456,15 +462,28 @@
     );
   }
 
-  _rowChanged(_: HTMLElement, oldRow: HTMLElement) {
-    if (oldRow) {
-      oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+  private fireCursorMoved(
+    event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
+    row: HTMLElement,
+    side: Side
+  ) {
+    const address = this.getAddressFor(row, side);
+    if (address) {
+      const {leftSide, number} = address;
+      row.dispatchEvent(
+        new CustomEvent<LineNumberEventDetail>(event, {
+          detail: {
+            lineNum: number,
+            side: leftSide ? Side.LEFT : Side.RIGHT,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
     }
-    this._updateSideClass();
   }
 
-  @observe('side')
-  _updateSideClass() {
+  private updateSideClass() {
     if (!this.diffRow) {
       return;
     }
@@ -498,69 +517,53 @@
     );
   }
 
-  /**
-   * Setup and tear down on-render listeners for any diffs that are added or
-   * removed from the cursor.
-   */
-  @observe('diffs.splices')
-  _diffsChanged(changeRecord: PolymerSpliceChange<GrDiff[]>) {
-    if (!changeRecord) {
-      return;
+  replaceDiffs(diffs: GrDiffCursorable[]) {
+    for (const diff of this.diffs) {
+      this.removeEventListeners(diff);
     }
-
+    this.diffs = [];
+    for (const diff of diffs) {
+      this.addEventListeners(diff);
+    }
+    this.diffs.push(...diffs);
     this._updateStops();
+  }
 
-    let splice;
-    let i;
-    for (
-      let spliceIdx = 0;
-      changeRecord.indexSplices && spliceIdx < changeRecord.indexSplices.length;
-      spliceIdx++
-    ) {
-      splice = changeRecord.indexSplices[spliceIdx];
-
-      // Removals must come before additions, because the gr-diff instances
-      // might be the same.
-      for (i = 0; i < splice?.removed.length; i++) {
-        splice.removed[i].removeEventListener(
-          'loading-changed',
-          this.boundHandleDiffLoadingChanged
-        );
-        splice.removed[i].removeEventListener(
-          'render-start',
-          this._boundHandleDiffRenderStart
-        );
-        splice.removed[i].removeEventListener(
-          'render-content',
-          this._boundHandleDiffRenderContent
-        );
-        splice.removed[i].removeEventListener(
-          'line-selected',
-          this._boundHandleDiffLineSelected
-        );
-      }
-
-      for (i = splice.index; i < splice.index + splice.addedCount; i++) {
-        this.diffs[i].addEventListener(
-          'loading-changed',
-          this.boundHandleDiffLoadingChanged
-        );
-        this.diffs[i].addEventListener(
-          'render-start',
-          this._boundHandleDiffRenderStart
-        );
-        this.diffs[i].addEventListener(
-          'render-content',
-          this._boundHandleDiffRenderContent
-        );
-        this.diffs[i].addEventListener(
-          'line-selected',
-          this._boundHandleDiffLineSelected
-        );
-      }
+  unregisterDiff(diff: GrDiffCursorable) {
+    // This can happen during destruction - just don't unregister then.
+    if (!this.diffs) return;
+    const i = this.diffs.indexOf(diff);
+    if (i !== -1) {
+      this.diffs.splice(i, 1);
     }
   }
 
+  private removeEventListeners(diff: GrDiffCursorable) {
+    diff.removeEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.removeEventListener(
+      'render-content',
+      this._boundHandleDiffRenderContent
+    );
+    diff.removeEventListener(
+      'line-selected',
+      this._boundHandleDiffLineSelected
+    );
+  }
+
+  private addEventListeners(diff: GrDiffCursorable) {
+    diff.addEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
+    diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
+  }
+
   _findRowByNumberAndFile(
     targetNumber: number,
     side: Side,
@@ -581,8 +584,97 @@
   }
 }
 
+// TODO(oler): Remove this once clients have migrated to using GrDiffCursor service.
+@customElement('gr-diff-cursor')
+export class GrDiffCursorElement extends PolymerElement {
+  static get template() {
+    return html``;
+  }
+
+  @property({type: String, observer: '_sideChanged'})
+  side: Side = Side.RIGHT;
+
+  _sideChanged(side: Side) {
+    this.cursor.side = side;
+  }
+
+  @property({type: Object}) diffs: GrDiff[] = [];
+
+  @observe('diffs.splices')
+  _diffsChanged() {
+    if (this.diffs) {
+      this.cursor.replaceDiffs(this.diffs);
+    }
+  }
+
+  private cursor = new GrDiffCursor();
+
+  /** @override */
+  ready() {
+    super.ready();
+    afterNextRender(this, () => {
+      /*
+      This represents the diff cursor is ready for interaction coming from
+      client components. It is more then Polymer "ready" lifecycle, as no
+      "ready" events are automatically fired by Polymer, it means
+      the cursor is completely interactable - in this case attached and
+      painted on the page. We name it "ready" instead of "rendered" as the
+      long-term goal is to make gr-diff-cursor a javascript class - not a DOM
+      element with an actual lifecycle. This will be triggered only once
+      per element.
+      */
+      this.dispatchEvent(
+        new CustomEvent('ready', {
+          composed: true,
+          bubbles: false,
+        })
+      );
+    });
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    this.cursor.dispose();
+    super.disconnectedCallback();
+  }
+
+  isAtStart = this.cursor.isAtStart.bind(this.cursor);
+
+  isAtEnd = this.cursor.isAtEnd.bind(this.cursor);
+
+  moveLeft = this.cursor.moveLeft.bind(this.cursor);
+
+  moveRight = this.cursor.moveRight.bind(this.cursor);
+
+  moveDown = this.cursor.moveDown.bind(this.cursor);
+
+  moveUp = this.cursor.moveUp.bind(this.cursor);
+
+  moveToNextChunk = this.cursor.moveToNextChunk.bind(this.cursor);
+
+  moveToPreviousChunk = this.cursor.moveToPreviousChunk.bind(this.cursor);
+
+  moveToNextCommentThread = this.cursor.moveToNextCommentThread.bind(
+    this.cursor
+  );
+
+  moveToPreviousCommentThread = this.cursor.moveToPreviousCommentThread.bind(
+    this.cursor
+  );
+
+  moveToLineNumber = this.cursor.moveToLineNumber.bind(this.cursor);
+
+  moveToFirstChunk = this.cursor.moveToFirstChunk.bind(this.cursor);
+
+  moveToLastChunk = this.cursor.moveToLastChunk.bind(this.cursor);
+
+  reInit = this.cursor.resetScrollMode.bind(this.cursor);
+
+  createCommentInPlace = this.cursor.createCommentInPlace.bind(this.cursor);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-diff-cursor': GrDiffCursor;
+    'gr-diff-cursor': GrDiffCursorElement;
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 7b72da8..3e63b0a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -22,26 +22,23 @@
 import {listenOnce} from '../../../test/test-utils.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import {createDefaultDiffPrefs} from '../../../constants/constants.js';
+import {GrDiffCursor} from './gr-diff-cursor.js';
 
 const basicFixture = fixtureFromTemplate(html`
   <gr-diff></gr-diff>
-  <gr-diff-cursor></gr-diff-cursor>
 `);
 
-const emptyFixture = fixtureFromElement('div');
-
 suite('gr-diff-cursor tests', () => {
-  let cursorElement;
+  let cursor;
   let diffElement;
   let diff;
 
   setup(done => {
-    const fixtureElems = basicFixture.instantiate();
-    diffElement = fixtureElems[0];
-    cursorElement = fixtureElems[1];
+    diffElement = basicFixture.instantiate();
+    cursor = new GrDiffCursor();
 
     // Register the diff with the cursor.
-    cursorElement.push('diffs', diffElement);
+    cursor.replaceDiffs([diffElement]);
 
     diffElement.loggedIn = false;
     diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
@@ -52,8 +49,8 @@
     };
     diffElement.path = 'some/path.ts';
     const setupDone = () => {
-      cursorElement._updateStops();
-      cursorElement.moveToFirstChunk();
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
       diffElement.removeEventListener('render', setupDone);
       done();
     };
@@ -66,21 +63,21 @@
 
   test('diff cursor functionality (side-by-side)', () => {
     // The cursor has been initialized to the first delta.
-    assert.isOk(cursorElement.diffRow);
+    assert.isOk(cursor.diffRow);
 
     const firstDeltaRow = diffElement.shadowRoot
         .querySelector('.section.delta .diff-row');
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, firstDeltaRow);
 
-    cursorElement.moveDown();
+    cursor.moveDown();
 
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
 
-    cursorElement.moveUp();
+    cursor.moveUp();
 
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
+    assert.equal(cursor.diffRow, firstDeltaRow);
   });
 
   test('moveToFirstChunk', async () => {
@@ -116,24 +113,24 @@
     // moveToFirstChunk() works correctly even if the button is not shown.
     diffElement.prefs.show_file_comment_button = false;
     await flush();
-    cursorElement._updateStops();
+    cursor._updateStops();
 
     const chunks = Array.from(diffElement.root.querySelectorAll(
         '.section.delta'));
     assert.equal(chunks.length, 2);
 
     // Verify it works on fresh diff.
-    cursorElement.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
-    assert.equal(cursorElement.side, 'right');
+    cursor.moveToFirstChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.side, 'right');
 
     // Verify it works from other cursor positions.
-    cursorElement.moveToNextChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
-    assert.equal(cursorElement.side, 'left');
-    cursorElement.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
-    assert.equal(cursorElement.side, 'right');
+    cursor.moveToNextChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'left');
+    cursor.moveToFirstChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.side, 'right');
   });
 
   test('moveToLastChunk', async () => {
@@ -166,45 +163,45 @@
 
     diffElement.diff = diff;
     await flush();
-    cursorElement._updateStops();
+    cursor._updateStops();
 
     const chunks = Array.from(diffElement.root.querySelectorAll(
         '.section.delta'));
     assert.equal(chunks.length, 2);
 
     // Verify it works on fresh diff.
-    cursorElement.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
-    assert.equal(cursorElement.side, 'right');
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'right');
 
     // Verify it works from other cursor positions.
-    cursorElement.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
-    assert.equal(cursorElement.side, 'left');
-    cursorElement.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
-    assert.equal(cursorElement.side, 'right');
+    cursor.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.side, 'left');
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'right');
   });
 
   test('cursor scroll behavior', () => {
-    assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
 
     diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursorElement.cursorManager.focusOnMove);
+    assert.isTrue(cursor.cursorManager.focusOnMove);
 
     window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursorElement.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursorElement.cursorManager.focusOnMove);
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
 
     diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursorElement.cursorManager.focusOnMove);
+    assert.isTrue(cursor.cursorManager.focusOnMove);
 
-    cursorElement.reInitCursor();
-    assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
   });
 
   test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
 
     diffElement.dispatchEvent(
         new CustomEvent('line-selected', {
@@ -222,7 +219,7 @@
       // We must allow the diff to re-render after setting the viewMode.
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
+        cursor.reInitCursor();
         done();
       };
       diffElement.addEventListener('render', renderHandler);
@@ -231,25 +228,25 @@
 
     test('diff cursor functionality (unified)', () => {
       // The cursor has been initialized to the first delta.
-      assert.isOk(cursorElement.diffRow);
+      assert.isOk(cursor.diffRow);
 
       let firstDeltaRow = diffElement.shadowRoot
           .querySelector('.section.delta .diff-row');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow);
 
       firstDeltaRow = diffElement.shadowRoot
           .querySelector('.section.delta .diff-row');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow);
 
-      cursorElement.moveDown();
+      cursor.moveDown();
 
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.notEqual(cursor.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
 
-      cursorElement.moveUp();
+      cursor.moveUp();
 
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursor.diffRow, firstDeltaRow);
     });
   });
 
@@ -264,29 +261,29 @@
 
     // Because the first delta in this diff is on the right, it should be set
     // to the right side.
-    assert.equal(cursorElement.side, 'right');
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
-    const firstIndex = cursorElement.cursorManager.index;
+    assert.equal(cursor.side, 'right');
+    assert.equal(cursor.diffRow, firstDeltaRow);
+    const firstIndex = cursor.cursorManager.index;
 
     // Move the side to the left. Because this delta only has a right side, we
     // should be moved up to the previous line where there is content on the
     // right. The previous row is part of the previous section.
-    cursorElement.moveLeft();
+    cursor.moveLeft();
 
-    assert.equal(cursorElement.side, 'left');
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.cursorManager.index, firstIndex - 1);
-    assert.equal(cursorElement.diffRow.parentElement,
+    assert.equal(cursor.side, 'left');
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+    assert.equal(cursor.diffRow.parentElement,
         firstDeltaSection.previousSibling);
 
     // If we move down, we should skip everything in the first delta because
     // we are on the left side and the first delta has no content on the left.
-    cursorElement.moveDown();
+    cursor.moveDown();
 
-    assert.equal(cursorElement.side, 'left');
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.isTrue(cursorElement.cursorManager.index > firstIndex);
-    assert.equal(cursorElement.diffRow.parentElement,
+    assert.equal(cursor.side, 'left');
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+    assert.equal(cursor.diffRow.parentElement,
         firstDeltaSection.nextSibling);
   });
 
@@ -299,26 +296,26 @@
 
     // We should be initialized to the first chunk. Since this chunk only has
     // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
     assert.equal(currentIndex, 0);
-    assert.equal(cursorElement.side, 'right');
+    assert.equal(cursor.side, 'right');
 
     // Move to the next chunk.
-    cursorElement.moveToNextChunk();
+    cursor.moveToNextChunk();
 
     // Since this chunk only has content on the left side. we should have been
     // automatically moved over.
     const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
     assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursorElement.side, 'left');
+    assert.equal(cursor.side, 'left');
   });
 
   suite('moved chunks without line range)', () => {
     setup(done => {
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
+        cursor.reInitCursor();
         done();
       };
       diffElement.addEventListener('render', renderHandler);
@@ -369,7 +366,7 @@
     setup(done => {
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
+        cursor.reInitCursor();
         done();
       };
       diffElement.addEventListener('render', renderHandler);
@@ -447,19 +444,19 @@
 
   test('initialLineNumber not provided', done => {
     let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk')
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
         .callsFake(() => {
-          scrollBehaviorDuringMove = cursorElement.cursorManager.scrollMode;
+          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
         });
 
     function renderHandler() {
       diffElement.removeEventListener('render', renderHandler);
-      cursorElement.reInitCursor();
+      cursor.reInitCursor();
       assert.isFalse(moveToNumStub.called);
       assert.isTrue(moveToChunkStub.called);
       assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
       done();
     }
     diffElement.addEventListener('render', renderHandler);
@@ -468,34 +465,34 @@
 
   test('initialLineNumber provided', done => {
     let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber')
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
         .callsFake(() => {
-          scrollBehaviorDuringMove = cursorElement.cursorManager.scrollMode;
+          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
         });
-    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
     function renderHandler() {
       diffElement.removeEventListener('render', renderHandler);
-      cursorElement.reInitCursor();
+      cursor.reInitCursor();
       assert.isFalse(moveToChunkStub.called);
       assert.isTrue(moveToNumStub.called);
       assert.equal(moveToNumStub.lastCall.args[0], 10);
       assert.equal(moveToNumStub.lastCall.args[1], 'right');
       assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
       done();
     }
     diffElement.addEventListener('render', renderHandler);
-    cursorElement.initialLineNumber = 10;
-    cursorElement.side = 'right';
+    cursor.initialLineNumber = 10;
+    cursor.side = 'right';
 
     diffElement._diffChanged(getMockDiffResponse());
   });
 
   test('getTargetDiffElement', () => {
-    cursorElement.initialLineNumber = 1;
-    assert.isTrue(!!cursorElement.diffRow);
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
     assert.equal(
-        cursorElement.getTargetDiffElement(),
+        cursor.getTargetDiffElement(),
         diffElement
     );
   });
@@ -506,7 +503,7 @@
     });
 
     test('adds new draft for selected line on the left', done => {
-      cursorElement.moveToLineNumber(2, 'left');
+      cursor.moveToLineNumber(2, 'left');
       diffElement.addEventListener('create-comment', e => {
         const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 2);
@@ -514,11 +511,11 @@
         assert.equal(side, 'left');
         done();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
     });
 
     test('adds draft for selected line on the right', done => {
-      cursorElement.moveToLineNumber(4, 'right');
+      cursor.moveToLineNumber(4, 'right');
       diffElement.addEventListener('create-comment', e => {
         const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 4);
@@ -526,7 +523,7 @@
         assert.equal(side, 'right');
         done();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
     });
 
     test('creates comment for range if selected', done => {
@@ -547,15 +544,15 @@
         assert.equal(side, 'right');
         done();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
     });
 
     test('ignores call if nothing is selected', () => {
       const createRangeCommentStub = sinon.stub(diffElement,
           'createRangeComment');
       const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursorElement.diffRow = undefined;
-      cursorElement.createCommentInPlace();
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
       assert.isFalse(createRangeCommentStub.called);
       assert.isFalse(addDraftAtLineStub.called);
     });
@@ -563,31 +560,31 @@
 
   test('getAddress', () => {
     // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursorElement.getAddress(),
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 5});
 
     // Revision line 4 is up.
-    cursorElement.moveUp();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 4});
 
     // Base line 4 is left.
-    cursorElement.moveLeft();
-    assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
 
     // Moving to the next chunk takes it back to the start.
-    cursorElement.moveToNextChunk();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 5});
 
     // The following chunk is a removal starting on line 10 of the base.
-    cursorElement.moveToNextChunk();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: true, number: 10});
 
     // Should be null if there is no selection.
-    cursorElement.cursorManager.unsetCursor();
-    assert.isNotOk(cursorElement.getAddress());
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
   });
 
   test('_findRowByNumberAndFile', () => {
@@ -595,43 +592,25 @@
     const row = diffElement.root.querySelectorAll('tr')[9];
 
     // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
+    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
   });
 
   test('expand context updates stops', done => {
-    sinon.spy(cursorElement, '_updateStops');
+    sinon.spy(cursor, '_updateStops');
     MockInteractions.tap(diffElement.shadowRoot
         .querySelector('gr-context-controls').shadowRoot
         .querySelector('.showContext'));
     flush(() => {
-      assert.isTrue(cursorElement._updateStops.called);
+      assert.isTrue(cursor._updateStops.called);
       done();
     });
   });
 
   test('updates stops when loading changes', () => {
-    sinon.spy(cursorElement, '_updateStops');
+    sinon.spy(cursor, '_updateStops');
     diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursorElement._updateStops.called);
-  });
-
-  suite('gr-diff-cursor event tests', () => {
-    let someEmptyDiv;
-
-    setup(() => {
-      someEmptyDiv = emptyFixture.instantiate();
-    });
-
-    teardown(() => sinon.restore());
-
-    test('ready is fired after component is rendered', done => {
-      const cursorElement = document.createElement('gr-diff-cursor');
-      cursorElement.addEventListener('ready', () => {
-        done();
-      });
-      someEmptyDiv.appendChild(cursorElement);
-    });
+    assert.isTrue(cursor._updateStops.called);
   });
 
   suite('multi diff', () => {
@@ -639,18 +618,16 @@
       <gr-diff></gr-diff>
       <gr-diff></gr-diff>
       <gr-diff></gr-diff>
-      <gr-diff-cursor></gr-diff-cursor>
     `);
 
     let diffElements;
 
     setup(() => {
-      const fixtureElems = multiDiffFixture.instantiate();
-      diffElements = fixtureElems.slice(0, 3);
-      cursorElement = fixtureElems[3];
+      diffElements = multiDiffFixture.instantiate();
+      cursor = new GrDiffCursor();
 
       // Register the diff with the cursor.
-      cursorElement.push('diffs', ...diffElements);
+      cursor.replaceDiffs(diffElements);
 
       for (const el of diffElements) {
         el.prefs = createDefaultDiffPrefs();
@@ -664,7 +641,7 @@
       // assertion because of the async nature assertion errors are handled and
       // can cause the test simply timing out, causing a lot of debugging headache.
       // Working with indices circumvents the problem.
-      return diffElements.indexOf(cursorElement.getTargetDiffElement());
+      return diffElements.indexOf(cursor.getTargetDiffElement());
     }
 
     test('do not skip loading diffs', async () => {
@@ -678,28 +655,28 @@
       const lastLine = diffElements[0].diff.meta_b.lines;
 
       // Goto second last line of the first diff
-      cursorElement.moveToLineNumber(lastLine - 1, 'right');
+      cursor.moveToLineNumber(lastLine - 1, 'right');
       assert.equal(
-          cursorElement.getTargetLineElement().textContent, lastLine - 1);
+          cursor.getTargetLineElement().textContent, lastLine - 1);
 
       // Can move down until we reach the loading file
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
 
       // Cannot move down while still loading the diff we would switch to
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
 
       // Diff 1 finishing to load
       diffElements[1].diff = getMockDiffResponse();
       await diffRenderedPromises[1];
 
       // Now we can go down
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursorElement.getTargetLineElement().textContent, 'File');
+      assert.equal(cursor.getTargetLineElement().textContent, 'File');
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index 6216644..cbd5047 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -36,6 +36,7 @@
   getSideByLineEl,
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../utils/common-util';
 
 interface SidedRange {
   side: Side;
@@ -574,7 +575,7 @@
   _getLength(node: Node | null): number {
     if (node === null) return 0;
     if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(node.querySelector('.contentText')!);
+      return this._getLength(queryAndAssert(node, '.contentText'));
     } else {
       return GrAnnotation.getLength(node);
     }
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 b42deda..72c7a62 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
@@ -398,7 +398,7 @@
       if (e instanceof Response) {
         this._handleGetDiffError(e);
       } else {
-        console.warn('Error encountered loading diff:', e);
+        this.reporting.error(e);
       }
     } finally {
       this.reporting.timeEnd(Timing.DIFF_TOTAL);
@@ -483,12 +483,12 @@
               });
             })
             .catch(err => {
-              console.warn('Applying coverage from provider failed: ', err);
+              this.reporting.error(err);
             });
         });
       })
       .catch(err => {
-        console.warn('Loading coverage ranges failed: ', err);
+        this.reporting.error(err);
       });
   }
 
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 b7ac0de..dacd8e7 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
@@ -71,7 +71,6 @@
   ConfigInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   FileInfo,
   NumericChangeId,
   ParentPatchSetNum,
@@ -106,7 +105,7 @@
 import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {toggleClass, getKeyboardEvent} from '../../../utils/dom-util';
-import {CursorMoveResult} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {CursorMoveResult} from '../../../api/core';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -128,7 +127,6 @@
 export interface GrDiffView {
   $: {
     commentAPI: GrCommentApi;
-    cursor: GrDiffCursor;
     diffHost: GrDiffHost;
     reviewed: HTMLInputElement;
     dropdown: GrDropdownList;
@@ -276,6 +274,11 @@
   @property({type: Number})
   _focusLineNum?: number;
 
+  private getReviewedParams: {
+    changeNum?: NumericChangeId;
+    patchNum?: PatchSetNum;
+  } = {};
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -334,6 +337,8 @@
 
   _onRenderHandler?: EventListener;
 
+  private cursor = new GrDiffCursor();
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
@@ -345,22 +350,23 @@
     });
 
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.$.cursor.push('diffs', this.$.diffHost);
+    this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
-      this.$.cursor.reInitCursor();
+      this.cursor.reInitCursor();
     };
     this.$.diffHost.addEventListener('render', this._onRenderHandler);
   }
 
   /** @override */
   disconnectedCallback() {
+    this.cursor.dispose();
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
     }
     super.disconnectedCallback();
   }
 
-  _getLoggedIn() {
+  _getLoggedIn(): Promise<boolean> {
     return this.restApiService.getLoggedIn();
   }
 
@@ -453,8 +459,13 @@
   _setReviewed(reviewed: boolean) {
     if (this._editMode) return;
     this.$.reviewed.checked = reviewed;
-    if (!this._patchRange?.patchNum) return;
+    if (!this._patchRange?.patchNum || !this._path) return;
+    const path = this._path;
+    if (reviewed) this._reviewedFiles.add(path);
+    else this._reviewedFiles.delete(path);
     this._saveReviewedState(reviewed).catch(err => {
+      if (this._reviewedFiles.has(path)) this._reviewedFiles.delete(path);
+      else this._reviewedFiles.add(path);
       fireAlert(this, ERR_REVIEW_STATUS);
       throw err;
     });
@@ -492,14 +503,14 @@
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
     e.preventDefault();
-    this.$.cursor.moveLeft();
+    this.cursor.moveLeft();
   }
 
   _handleRightPane(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
     e.preventDefault();
-    this.$.cursor.moveRight();
+    this.cursor.moveRight();
   }
 
   _handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
@@ -519,14 +530,14 @@
 
     e.preventDefault();
     this.$.diffHost.displayLine = true;
-    this.$.cursor.moveUp();
+    this.cursor.moveUp();
   }
 
   _handleVisibleLine(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
     e.preventDefault();
-    this.$.cursor.moveToVisibleArea();
+    this.cursor.moveToVisibleArea();
   }
 
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
@@ -550,7 +561,7 @@
 
     e.preventDefault();
     this.$.diffHost.displayLine = true;
-    this.$.cursor.moveDown();
+    this.cursor.moveDown();
   }
 
   _moveToPreviousFileWithComment() {
@@ -598,7 +609,7 @@
 
     e.preventDefault();
     this.classList.remove('hideComments');
-    this.$.cursor.createCommentInPlace();
+    this.cursor.createCommentInPlace();
   }
 
   _handlePrevFile(e: CustomKeyboardEvent) {
@@ -628,18 +639,18 @@
 
     e.preventDefault();
     if (e.detail.keyboardEvent?.shiftKey) {
-      const result = this.$.cursor.moveToNextCommentThread();
+      const result = this.cursor.moveToNextCommentThread();
       if (result === CursorMoveResult.CLIPPED) {
         this._navigateToNextFileWithCommentThread();
       }
     } else {
       if (this.modifierPressed(e)) return;
-      const result = this.$.cursor.moveToNextChunk();
+      const result = this.cursor.moveToNextChunk();
       // navigate to next file if key is not being held down
       if (
         !e.detail.keyboardEvent?.repeat &&
         result === CursorMoveResult.CLIPPED &&
-        this.$.cursor.isAtEnd()
+        this.cursor.isAtEnd()
       ) {
         this.showToastAndNavigateFile('next', 'n');
       }
@@ -689,11 +700,11 @@
 
     e.preventDefault();
     if (e.detail.keyboardEvent?.shiftKey) {
-      this.$.cursor.moveToPreviousCommentThread();
+      this.cursor.moveToPreviousCommentThread();
     } else {
       if (this.modifierPressed(e)) return;
-      this.$.cursor.moveToPreviousChunk();
-      if (!e.detail.keyboardEvent?.repeat && this.$.cursor.isAtStart()) {
+      this.cursor.moveToPreviousChunk();
+      if (!e.detail.keyboardEvent?.repeat && this.cursor.isAtStart()) {
         this.showToastAndNavigateFile('previous', 'p');
       }
     }
@@ -848,7 +859,7 @@
     if (!this._patchRange) return;
 
     // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.$.cursor.getAddress();
+    const cursorAddress = this.cursor.getAddress();
     const editUrl = GerritNav.getEditUrlForDiff(
       this._change,
       this._path,
@@ -894,31 +905,26 @@
     return {path: fileList[idx]};
   }
 
-  _getReviewedFiles(
-    changeNum?: NumericChangeId,
-    patchNum?: PatchSetNum
-  ): Promise<Set<string>> {
-    if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
-    return this.restApiService
-      .getReviewedFiles(changeNum, patchNum)
-      .then(files => {
-        this._reviewedFiles = new Set(files);
-        return this._reviewedFiles;
-      });
+  _getReviewedFiles(changeNum?: NumericChangeId, patchNum?: PatchSetNum) {
+    if (!changeNum || !patchNum) return;
+    if (
+      this.getReviewedParams.changeNum === changeNum &&
+      this.getReviewedParams.patchNum === patchNum
+    ) {
+      return;
+    }
+    this.getReviewedParams = {
+      changeNum,
+      patchNum,
+    };
+    this.restApiService.getReviewedFiles(changeNum, patchNum).then(files => {
+      this._reviewedFiles = new Set(files);
+    });
   }
 
-  _getReviewedStatus(
-    editMode?: boolean,
-    changeNum?: NumericChangeId,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    if (editMode || !path) {
-      return Promise.resolve(false);
-    }
-    return this._getReviewedFiles(changeNum, patchNum).then(files =>
-      files.has(path)
-    );
+  _getReviewedStatus(path: string) {
+    if (this._editMode) return false;
+    return this._reviewedFiles.has(path);
   }
 
   _initLineOfInterestAndCursor(leftSide: boolean) {
@@ -1093,7 +1099,9 @@
     // the top-level change info view) and therefore undefined in `params`.
     // If route is of type /comment/<commentId>/ then no patchNum is present
     if (!value.patchNum && !value.commentLink) {
-      console.warn('invalid url, no patchNum found');
+      this.reporting.error(
+        new Error(`Invalid diff view URL, no patchNum found: ${value}`)
+      );
       return;
     }
 
@@ -1197,44 +1205,39 @@
     }
   }
 
-  @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+  @observe('_path', '_prefs', '_reviewedFiles')
   _setReviewedObserver(
+    path?: string,
+    prefs?: DiffPreferencesInfo,
+    reviewedFiles?: Set<string>
+  ) {
+    if (prefs === undefined) return;
+    if (path === undefined) return;
+    if (reviewedFiles === undefined) return;
+    if (prefs.manual_review) {
+      // Checkbox state needs to be set explicitly only when manual_review
+      // is specified.
+      this.$.reviewed.checked = this._getReviewedStatus(path);
+    } else {
+      this._setReviewed(true);
+    }
+  }
+
+  @observe('_loggedIn', '_changeNum', '_patchRange')
+  getReviewedFiles(
     _loggedIn?: boolean,
-    paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
-    _prefs?: DiffPreferencesInfo,
-    patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+    _changeNum?: NumericChangeId,
+    patchRange?: PatchRange
   ) {
     if (_loggedIn === undefined) return;
-    if (paramsRecord === undefined) return;
-    if (_prefs === undefined) return;
-    if (patchRangeRecord === undefined) return;
-    if (patchRangeRecord.base === undefined) return;
+    if (_changeNum === undefined) return;
+    if (patchRange === undefined) return;
 
-    const patchRange = patchRangeRecord.base;
     if (!_loggedIn) {
       return;
     }
 
-    if (_prefs.manual_review) {
-      // Checkbox state needs to be set explicitly only when manual_review
-      // is specified.
-
-      if (patchRange.patchNum) {
-        this._getReviewedStatus(
-          this._editMode,
-          this._changeNum,
-          patchRange.patchNum,
-          this._path
-        ).then((status: boolean) => {
-          this.$.reviewed.checked = status;
-        });
-      }
-      return;
-    }
-
-    if (paramsRecord.base?.view === GerritNav.View.DIFF) {
-      this._setReviewed(true);
-    }
+    this._getReviewedFiles(this._changeNum, patchRange.patchNum);
   }
 
   /**
@@ -1245,11 +1248,11 @@
       return;
     }
     if (leftSide) {
-      this.$.cursor.side = Side.LEFT;
+      this.cursor.side = Side.LEFT;
     } else {
-      this.$.cursor.side = Side.RIGHT;
+      this.cursor.side = Side.RIGHT;
     }
-    this.$.cursor.initialLineNumber = this._focusLineNum;
+    this.cursor.initialLineNumber = this._focusLineNum;
   }
 
   _getLineOfInterest(leftSide: boolean): LineOfInterest | undefined {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 7897a4a..8d69007d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -429,6 +429,5 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-diff-cursor id="cursor"></gr-diff-cursor>
   <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 39ec384..ebcfa18 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -30,7 +30,7 @@
 } from '../../../test/test-data-generators.js';
 import {EditPatchSetNum} from '../../../types/common.js';
 import sinon from 'sinon/pkg/sinon-esm';
-import {CursorMoveResult} from '../../shared/gr-cursor-manager/gr-cursor-manager.js';
+import {CursorMoveResult} from '../../../api/core.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -91,7 +91,6 @@
     }
 
     let getDiffChangeDetailStub;
-    let getReviewedFilesStub;
     setup(async () => {
       clock = sinon.useFakeTimers();
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
@@ -105,8 +104,6 @@
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
-      getReviewedFilesStub = stubRestApi('getReviewedFiles').returns(
-          Promise.resolve([]));
 
       element = basicFixture.instantiate();
       element._changeNum = '42';
@@ -422,19 +419,19 @@
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
-      let scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
+      let scrollStub = sinon.stub(element.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+      scrollStub = sinon.stub(element.cursor, 'moveToPreviousChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
+      scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
       MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor,
+      scrollStub = sinon.stub(element.cursor,
           'moveToPreviousCommentThread');
       MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
@@ -470,7 +467,7 @@
     test('moveToNextCommentThread navigates to next file', () => {
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       const diffChangeStub = sinon.stub(element, '_navigateToChange');
-      sinon.stub(element.$.cursor, 'isAtEnd').returns(true);
+      sinon.stub(element.cursor, 'isAtEnd').returns(true);
       element._changeNum = '42';
       const comment = {
         'wheatley.md': [{
@@ -857,7 +854,7 @@
           b: {_number: 2, commit: {parents: []}},
         },
       };
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: lineNumber, isLeftSide: false});
       const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
       flush(() => {
@@ -1157,10 +1154,11 @@
       const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
           .callsFake(() => Promise.resolve());
       const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
-          .callsFake(() => Promise.resolve());
+          .returns(false);
 
       sinon.stub(element.$.diffHost, 'reload');
       element._loggedIn = true;
+      element._prefs = {manual_review: true};
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
@@ -1172,17 +1170,19 @@
         patchNum: 2,
         basePatchNum: 1,
       };
-      element._prefs = {manual_review: true};
       flush();
 
       assert.isFalse(saveReviewedStub.called);
       assert.isTrue(getReviewedStub.called);
 
+      const oldCount = getReviewedStub.callCount;
+
       element._prefs = {};
+      element._path = 'abcd';
       flush();
 
       assert.isTrue(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.calledOnce);
+      assert.equal(getReviewedStub.callCount, oldCount);
     });
 
     test('file review status', () => {
@@ -1202,6 +1202,7 @@
         patchNum: 2,
         basePatchNum: 1,
       };
+      element._path = 'abcd';
       element._prefs = {};
       flush();
 
@@ -1380,34 +1381,34 @@
     });
 
     test('_initCursor', () => {
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when params specify no cursor address:
       element._initCursor(false);
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when params specify side but no number:
       element._initCursor(true);
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Revision hash: specifies lineNum but not side.
 
       element._focusLineNum = 234;
       element._initCursor(false);
-      assert.equal(element.$.cursor.initialLineNumber, 234);
-      assert.equal(element.$.cursor.side, 'right');
+      assert.equal(element.cursor.initialLineNumber, 234);
+      assert.equal(element.cursor.side, 'right');
 
       // Base hash: specifies lineNum and side.
       element._focusLineNum = 345;
       element._initCursor(true);
-      assert.equal(element.$.cursor.initialLineNumber, 345);
-      assert.equal(element.$.cursor.side, 'left');
+      assert.equal(element.cursor.initialLineNumber, 345);
+      assert.equal(element.cursor.side, 'left');
 
       // Specifies right side:
       element._focusLineNum = 123;
       element._initCursor(false);
-      assert.equal(element.$.cursor.initialLineNumber, 123);
-      assert.equal(element.$.cursor.side, 'right');
+      assert.equal(element.cursor.initialLineNumber, 123);
+      assert.equal(element.cursor.side, 'right');
     });
 
     test('_getLineOfInterest', () => {
@@ -1426,7 +1427,7 @@
     test('_onLineSelected', () => {
       const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sinon.stub(history, 'replaceState');
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: false});
 
       element._changeNum = 321;
@@ -1448,7 +1449,7 @@
     test('line selected on left side', () => {
       const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sinon.stub(history, 'replaceState');
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: true});
 
       element._changeNum = 321;
@@ -1673,25 +1674,6 @@
           [{value: '/foo'}, {value: '/bar'}]), 'show');
     });
 
-    test('_getReviewedStatus', () => {
-      const promises = [];
-      getReviewedFilesStub.returns(Promise.resolve(['path']));
-
-      promises.push(element._getReviewedStatus(true, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, 3, 5, 'path')
-          .then(reviewed => assert.isTrue(reviewed)));
-
-      return Promise.all(promises);
-    });
-
     test('f open file dropdown', () => {
       assert.isFalse(element.$.dropdown.$.dropdown.opened);
       MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
@@ -1751,11 +1733,11 @@
             element, 'dispatchEvent').callThrough();
         navToFileStub = sinon.stub(element, '_navToFile');
         moveToPreviousChunkStub =
-            sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+            sinon.stub(element.cursor, 'moveToPreviousChunk');
         moveToNextChunkStub =
-            sinon.stub(element.$.cursor, 'moveToNextChunk');
-        isAtStartStub = sinon.stub(element.$.cursor, 'isAtStart');
-        isAtEndStub = sinon.stub(element.$.cursor, 'isAtEnd');
+            sinon.stub(element.cursor, 'moveToNextChunk');
+        isAtStartStub = sinon.stub(element.cursor, 'isAtStart');
+        isAtEndStub = sinon.stub(element.cursor, 'isAtEnd');
         nowStub = sinon.stub(Date, 'now');
       });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 486bf3a..7c437fd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -62,11 +62,10 @@
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {AbortStop} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {MovedLinkClickedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
-
+import {AbortStop} from '../../../api/core';
 import {
   CreateCommentEventDetail as CreateCommentEventDetailApi,
   RenderPreferences,
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
index 131825a..948578d 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -36,9 +36,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
index ac015e1..df0d71a 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -118,9 +118,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 0c6dd4d..0c36cd5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -57,7 +57,7 @@
         try {
           mayContinue = callback(e);
         } catch (exception) {
-          console.warn(`Plugin error handing event: ${exception}`);
+          this.reporting.error(exception);
         }
         if (mayContinue === false) {
           e.stopImmediatePropagation();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 7ef5a73..d278d22 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -105,6 +105,8 @@
       })
     );
 
+    promises.push(this.restApiService.invalidateAccountsDetailCache());
+
     promises.push(
       this.restApiService.getAccount().then(account => {
         if (!account) return;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
index 6ca484d..e530ac8 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -35,7 +35,7 @@
     <section>
       <span class="title"></span>
       <span class="value">
-        <gr-avatar account="[[_account]]" image-size="120"></gr-avatar>
+        <gr-avatar account="[[_account]]" imageSize="120"></gr-avatar>
       </span>
     </section>
     <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index ab4c5a5..d7078ed 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -97,6 +97,13 @@
   _config?: ServerInfo;
 
   @property({type: Boolean, reflectToAttribute: true})
+  selectionChipStyle = false;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    observer: 'selectedChanged',
+  })
   selected = false;
 
   @property({type: Boolean, reflectToAttribute: true})
@@ -126,6 +133,10 @@
     });
   }
 
+  selectedChanged(selected?: boolean) {
+    this.deselected = !selected;
+  }
+
   _isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index c5b66ce3..a642337 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -37,13 +37,13 @@
     :host::after {
       content: var(--account-label-suffix);
     }
-    :host([deselected]) {
+    :host([deselected][selection-chip-style]) {
       background-color: var(--background-color-primary);
       border: 1px solid var(--comment-separator-color);
       border-radius: 8px;
       color: var(--deemphasized-text-color);
     }
-    :host([selected]) {
+    :host([selected][selection-chip-style]) {
       background-color: var(--chip-selected-background-color);
       border: 1px solid var(--chip-selected-background-color);
       border-radius: 8px;
@@ -128,7 +128,7 @@
     class$="[[_computeHasAttentionClass(highlightAttention, account, change, forceAttention)]]"
   >
     <template is="dom-if" if="[[!hideAvatar]]">
-      <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
+      <gr-avatar account="[[account]]" imageSize="32"></gr-avatar>
     </template>
     <span class="text">
       <span class="name">[[_computeName(account, _config, firstName)]]</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 231fc36..130969e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -326,7 +326,9 @@
         return;
       }
     }
-    console.warn('received remove event for missing account', toRemove);
+    this.reporting.error(
+      new Error(`Received "remove" event for missing account: ${toRemove}`)
+    );
   }
 
   _getNativeInput(paperInput: PaperInputElementExt) {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index e30e995..80576a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -14,22 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-avatar_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property} from 'lit-element';
 
 @customElement('gr-avatar')
-export class GrAvatar extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object, observer: '_accountChanged'})
+export class GrAvatar extends GrLitElement {
+  @property({type: Object})
   account?: AccountInfo;
 
   @property({type: Number})
@@ -40,6 +34,27 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          border-radius: 50%;
+          background-size: cover;
+          background-color: var(
+            --avatar-background-color,
+            var(--gray-background)
+          );
+        }
+      `,
+    ];
+  }
+
+  render() {
+    this._updateAvatarURL();
+    return html``;
+  }
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
@@ -57,10 +72,6 @@
     return this.restApiService.getConfig();
   }
 
-  _accountChanged() {
-    this._updateAvatarURL();
-  }
-
   _updateAvatarURL() {
     if (!this._hasAvatars || !this.account) {
       this.hidden = true;
@@ -80,7 +91,7 @@
     );
   }
 
-  _buildAvatarURL(account: AccountInfo) {
+  _buildAvatarURL(account?: AccountInfo) {
     if (!account) {
       return '';
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
deleted file mode 100644
index a1e51df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      border-radius: 50%;
-      background-size: cover;
-      background-color: var(--avatar-background-color, var(--gray-background));
-    }
-  </style>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index a4f46db..65e8e9f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -28,13 +28,15 @@
 import {ParsedChangeInfo} from '../../../types/types';
 
 export enum ChangeStates {
-  MERGED = 'Merged',
   ABANDONED = 'Abandoned',
+  ACTIVE = 'Active',
   MERGE_CONFLICT = 'Merge Conflict',
-  WIP = 'WIP',
+  MERGED = 'Merged',
   PRIVATE = 'Private',
+  READY_TO_SUBMIT = 'Ready to submit',
   REVERT_CREATED = 'Revert Created',
   REVERT_SUBMITTED = 'Revert Submitted',
+  WIP = 'WIP',
 }
 
 const WIP_TOOLTIP =
@@ -52,7 +54,7 @@
   'current reviewers (or anyone with "View Private Changes" permission).';
 
 @customElement('gr-change-status')
-class GrChangeStatus extends PolymerElement {
+export class GrChangeStatus extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -91,8 +93,12 @@
     resolveWeblinks?: GeneratedWebLink[],
     status?: ChangeStates
   ): boolean {
+    const isRevertCreatedOrSubmitted =
+      (status === ChangeStates.REVERT_SUBMITTED ||
+        status === ChangeStates.REVERT_CREATED) &&
+      revertedChange !== undefined;
     return (
-      revertedChange !== undefined ||
+      isRevertCreatedOrSubmitted ||
       !!(status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length)
     );
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
index 116dcaf..2ca2744b 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
@@ -87,7 +87,7 @@
   >
     <template
       is="dom-if"
-      if="[[!!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
+      if="[[hasStatusLink(revertedChange, resolveWeblinks, status)]]">
       <a class="status-link"
          href="[[getStatusLink(revertedChange, resolveWeblinks, status)]]">
         <div class="chip" aria-label$="Label: [[status]]">
@@ -100,7 +100,7 @@
         </div>
       </a>
     </template>
-    <template is="dom-if" if="[[!hasStatusLink(revertedChange)]]">
+    <template is="dom-if" if="[[!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
       <div class="chip" aria-label$="Label: [[status]]">
         [[_computeStatusString(status)]]
       </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
deleted file mode 100644
index 7bc2466..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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.
- */
-
-import sinon from 'sinon/pkg/sinon-esm';
-import '../../../test/common-test-setup-karma.js';
-import {createChange} from '../../../test/test-data-generators.js';
-import './gr-change-status.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status.js';
-
-const basicFixture = fixtureFromElement('gr-change-status');
-
-const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-    'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
-    'and email notifications will be silenced until the review is started.';
-
-const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-    'current reviewers (or anyone with "View Private Changes" permission).';
-
-suite('gr-change-status tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('WIP', () => {
-    element.status = 'WIP';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, 'Work in Progress');
-    assert.equal(element.tooltipText, WIP_TOOLTIP);
-    assert.isTrue(element.classList.contains('wip'));
-  });
-
-  test('WIP flat', () => {
-    element.flat = true;
-    element.status = 'WIP';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, 'WIP');
-    assert.isDefined(element.tooltipText);
-    assert.isTrue(element.classList.contains('wip'));
-    assert.isTrue(element.hasAttribute('flat'));
-  });
-
-  test('merged', () => {
-    element.status = 'Merged';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('merged'));
-    assert.isFalse(
-        element.showResolveIcon([{url: 'http://google.com'}], 'Merged'));
-  });
-
-  test('abandoned', () => {
-    element.status = 'Abandoned';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('abandoned'));
-  });
-
-  test('merge conflict', () => {
-    const status = 'Merge Conflict';
-    element.status = status;
-    flush();
-
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
-    assert.isTrue(element.classList.contains('merge-conflict'));
-    assert.isFalse(element.hasStatusLink(undefined, [], status));
-    assert.isFalse(element.showResolveIcon([], status));
-  });
-
-  test('merge conflict with resolve link', () => {
-    const status = 'Merge Conflict';
-    const url = 'http://google.com';
-    const weblinks = [{url}];
-
-    assert.isTrue(element.hasStatusLink(undefined, weblinks, status));
-    assert.equal(element.getStatusLink(undefined, weblinks, status), url);
-    assert.isTrue(element.showResolveIcon(weblinks, status));
-  });
-
-  test('reverted change', () => {
-    const url = 'http://google.com';
-    const status = 'Revert Submitted';
-    const revertedChange = createChange();
-    sinon.stub(GerritNav, 'getUrlForSearchQuery').returns(url);
-
-    assert.isTrue(element.hasStatusLink(revertedChange, [], status));
-    assert.equal(element.getStatusLink(revertedChange, [], status), url);
-  });
-
-  test('private', () => {
-    element.status = 'Private';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
-    assert.isTrue(element.classList.contains('private'));
-  });
-
-  test('active', () => {
-    element.status = 'Active';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('active'));
-  });
-
-  test('ready to submit', () => {
-    element.status = 'Ready to submit';
-    flush();
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('ready-to-submit'));
-  });
-
-  test('updating status removes the previous class', () => {
-    element.status = 'Private';
-    flush();
-    assert.isTrue(element.classList.contains('private'));
-    assert.isFalse(element.classList.contains('wip'));
-
-    element.status = 'WIP';
-    flush();
-    assert.isFalse(element.classList.contains('private'));
-    assert.isTrue(element.classList.contains('wip'));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
new file mode 100644
index 0000000..a56f6f1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -0,0 +1,172 @@
+/**
+ * @license
+ * Copyright (C) 2017 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.
+ */
+
+import sinon from 'sinon/pkg/sinon-esm';
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import './gr-change-status';
+import {ChangeStates, GrChangeStatus} from './gr-change-status';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status';
+
+const basicFixture = fixtureFromElement('gr-change-status');
+
+const WIP_TOOLTIP =
+  "This change isn't ready to be reviewed or submitted. " +
+  "It will not appear on dashboards unless you are CC'ed or assigned, " +
+  'and email notifications will be silenced until the review is started.';
+
+const PRIVATE_TOOLTIP =
+  'This change is only visible to its owner and ' +
+  'current reviewers (or anyone with "View Private Changes" permission).';
+
+suite('gr-change-status tests', () => {
+  let element: GrChangeStatus;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('WIP', () => {
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Work in Progress'
+    );
+    assert.equal(element.tooltipText, WIP_TOOLTIP);
+    assert.isTrue(element.classList.contains('wip'));
+  });
+
+  test('WIP flat', () => {
+    element.flat = true;
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'WIP'
+    );
+    assert.isDefined(element.tooltipText);
+    assert.isTrue(element.classList.contains('wip'));
+    assert.isTrue(element.hasAttribute('flat'));
+  });
+
+  test('merged', () => {
+    element.status = ChangeStates.MERGED;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Merged'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('merged'));
+    assert.isFalse(
+      element.showResolveIcon([{url: 'http://google.com'}], ChangeStates.MERGED)
+    );
+  });
+
+  test('abandoned', () => {
+    element.status = ChangeStates.ABANDONED;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Abandoned'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('abandoned'));
+  });
+
+  test('merge conflict', () => {
+    const status = ChangeStates.MERGE_CONFLICT;
+    element.status = status;
+    flush();
+
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Merge Conflict'
+    );
+    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
+    assert.isTrue(element.classList.contains('merge-conflict'));
+    assert.isFalse(element.hasStatusLink(undefined, [], status));
+    assert.isFalse(element.showResolveIcon([], status));
+  });
+
+  test('merge conflict with resolve link', () => {
+    const status = ChangeStates.MERGE_CONFLICT;
+    const url = 'http://google.com';
+    const weblinks = [{url}];
+
+    assert.isTrue(element.hasStatusLink(undefined, weblinks, status));
+    assert.equal(element.getStatusLink(undefined, weblinks, status), url);
+    assert.isTrue(element.showResolveIcon(weblinks, status));
+  });
+
+  test('reverted change', () => {
+    const url = 'http://google.com';
+    const status = ChangeStates.REVERT_SUBMITTED;
+    const revertedChange = createChange();
+    sinon.stub(GerritNav, 'getUrlForSearchQuery').returns(url);
+
+    assert.isTrue(element.hasStatusLink(revertedChange, [], status));
+    assert.equal(element.getStatusLink(revertedChange, [], status), url);
+  });
+
+  test('private', () => {
+    element.status = ChangeStates.PRIVATE;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Private'
+    );
+    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
+    assert.isTrue(element.classList.contains('private'));
+  });
+
+  test('active', () => {
+    element.status = ChangeStates.ACTIVE;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Active'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('active'));
+  });
+
+  test('ready to submit', () => {
+    element.status = ChangeStates.READY_TO_SUBMIT;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Ready to submit'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('ready-to-submit'));
+  });
+
+  test('updating status removes the previous class', () => {
+    element.status = ChangeStates.PRIVATE;
+    flush();
+    assert.isTrue(element.classList.contains('private'));
+    assert.isFalse(element.classList.contains('wip'));
+
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.isFalse(element.classList.contains('private'));
+    assert.isTrue(element.classList.contains('wip'));
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index cf8a0ed..d6ad030 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -41,6 +41,7 @@
 import {computeDisplayPath} from '../../../utils/path-list-util';
 import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
+  AccountDetailInfo,
   CommentRange,
   ConfigInfo,
   NumericChangeId,
@@ -62,6 +63,7 @@
 import {StorageLocation} from '../../../services/storage/gr-storage';
 import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
 import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
+import {getUserName} from '../../../utils/display-name-util';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -200,6 +202,9 @@
   @property({type: Boolean})
   showCommentContext = false;
 
+  @property({type: Object})
+  _selfAccount?: AccountDetailInfo;
+
   get keyBindings() {
     return {
       'e shift+e': '_handleEKey',
@@ -239,6 +244,9 @@
       };
       this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
     });
+    this.restApiService.getAccount().then(account => {
+      this._selfAccount = account;
+    });
     this._setInitialExpandedState();
   }
 
@@ -404,16 +412,16 @@
     return displayPath;
   }
 
-  _computeDisplayLine() {
-    if (this.lineNum === FILE) {
+  _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
+    if (lineNum === FILE) {
       if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
         return '';
       }
       return FILE;
     }
-    if (this.lineNum) return `#${this.lineNum}`;
+    if (lineNum) return `#${lineNum}`;
     // If range is set, then lineNum equals the end line of the range.
-    if (this.range) return `#${this.range.end_line}`;
+    if (range) return `#${range.end_line}`;
     return '';
   }
 
@@ -698,7 +706,9 @@
     const index = this._indexOf(comment, this.comments);
     if (index === -1) {
       // This should never happen: comment belongs to another thread.
-      console.warn('Comment update for another comment thread.');
+      this.reporting.error(
+        new Error(`Comment update for another comment thread: ${comment}`)
+      );
       return;
     }
     this.set(['comments', index], comment);
@@ -743,6 +753,17 @@
       this._projectConfig = config;
     });
   }
+
+  _computeAriaHeading(_orderedComments: UIComment[]) {
+    const firstComment = _orderedComments[0];
+    const author = firstComment?.author ?? this._selfAccount;
+    const lastComment = _orderedComments[_orderedComments.length - 1] || {};
+    const status = [
+      lastComment.unresolved ? 'Unresolved' : '',
+      isDraft(lastComment) ? 'Draft' : '',
+    ].join(' ');
+    return `${status} Comment thread by ${getUserName(undefined, author)}`;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 1f0ce3d..6ab82b7 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -123,12 +123,15 @@
       <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
         <a
           href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-          >[[_computeDisplayLine()]]</a
+          >[[_computeDisplayLine(lineNum, range)]]</a
         >
       </template>
     </div>
   </template>
   <div id="container">
+    <h3 class="assistive-tech-only">
+      [[_computeAriaHeading(_orderedComments)]]
+    </h3>
     <div class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box">
       <template
         id="commentList"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 64f5ad8..2eeb12d 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -285,15 +285,24 @@
 
     test('_computeDisplayLine', () => {
       element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        '#5'
+      );
 
       element.path = SpecialFilePath.COMMIT_MESSAGE;
       element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        '#5'
+      );
 
       element.lineNum = undefined;
       element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayLine(), '');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        ''
+      );
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index aad8c98..16605b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -269,12 +269,6 @@
               >From patchset [[comment.patch_set]]</span
             ></a
           >
-          <a
-            href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments"
-            target="_blank"
-          >
-            <iron-icon icon="gr-icons:bug" title="report a problem"></iron-icon>
-          </a>
         </template>
         <gr-tooltip-content
           class="draftTooltip"
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index a5a5433..017ba50 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -15,31 +15,10 @@
  * limitations under the License.
  */
 import {BehaviorSubject} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
 import {ScrollMode} from '../../../constants/constants';
 
 /**
- * Return type for cursor moves, that indicate whether a move was possible.
- */
-export enum CursorMoveResult {
-  /** The cursor was successfully moved. */
-  MOVED,
-  /** There were no stops - the cursor was reset. */
-  NO_STOPS,
-  /**
-   * There was no more matching stop to move to - the cursor was clipped to the
-   * end.
-   */
-  CLIPPED,
-  /** The abort condition would have been fulfilled for the new target. */
-  ABORTED,
-}
-
-/** A sentinel that can be inserted to disallow moving across. */
-export class AbortStop {}
-
-export type Stop = HTMLElement | AbortStop;
-
-/**
  * Type guard and checker to check if a stop can be targeted.
  * Abort stops cannot be targeted.
  */
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index 01b9a74..ba7e4f8 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -18,7 +18,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-cursor-manager.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {AbortStop, CursorMoveResult, GrCursorManager} from './gr-cursor-manager.js';
+import {AbortStop, CursorMoveResult} from '../../../api/core.js';
+import {GrCursorManager} from './gr-cursor-manager.js';
 
 const basicTestFixutre = fixtureFromTemplate(html`
     <ul>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 391028a..e93013e 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -22,6 +22,8 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {appContext} from '../../../services/app-context';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -73,8 +75,7 @@
   }
 
   focusOnCopy() {
-    // TODO(TS): remove ! assertion later
-    this.shadowRoot!.querySelector('gr-shell-command')!.focusOnCopy();
+    queryAndAssert<GrShellCommand>(this, 'gr-shell-command').focusOnCopy();
   }
 
   _getLoggedIn() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index eb44a60..83cd380 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -23,6 +23,9 @@
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../utils/common-util';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {Interaction} from '../../../constants/reporting';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -131,7 +134,10 @@
   }
 
   focusTextarea() {
-    this.shadowRoot!.querySelector('iron-autogrow-textarea')!.textarea.focus();
+    queryAndAssert<IronAutogrowTextareaElement>(
+      this,
+      'iron-autogrow-textarea'
+    ).textarea.focus();
   }
 
   _newContentChanged(newContent: string) {
@@ -227,7 +233,7 @@
 
   _toggleCommitCollapsed() {
     this._commitCollapsed = !this._commitCollapsed;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'Commit message',
       toState: !this._commitCollapsed ? 'Show all' : 'Show less',
     });
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 17b659f..7298af7 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
@@ -20,6 +20,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-formatted-text_html';
 import {CommentLinks} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
@@ -57,6 +58,8 @@
   @property({type: Boolean})
   noTrailingMargin = false;
 
+  private readonly reporting = appContext.reportingService;
+
   static get observers() {
     return ['_contentOrConfigChanged(content, config)'];
   }
@@ -304,7 +307,7 @@
         return ul;
       }
 
-      console.warn('Unrecognized type.');
+      this.reporting.error(new Error(`Unrecognized block type: ${block.type}`));
       return document.createElement('span');
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index d0986de..dac5962 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -75,7 +75,7 @@
     <template is="dom-if" if="[[_isShowing]]">
       <div class="top">
         <div class="avatar">
-          <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
+          <gr-avatar account="[[account]]" imageSize="56"></gr-avatar>
         </div>
         <div class="account">
           <h3 class="name heading-3">[[account.name]]</h3>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index e3a34c8..8968e18 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -148,6 +148,8 @@
       <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
       <g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#code-->
+      <g id="code"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 3f75970..82c7118 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -42,7 +42,9 @@
   ): GrAnnotationActionsInterface {
     this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
     if (this.coverageProvider) {
-      console.warn('Overwriting an existing coverage provider.');
+      this.reporting.error(
+        new Error(`Overwriting cov provider: ${this.plugin.getPluginName()}`)
+      );
     }
     this.coverageProvider = coverageProvider;
     return this;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 15d4680..b9c4c32 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -80,7 +80,7 @@
    */
   private setEl(el?: GrChangeActionsElement) {
     if (!el) {
-      console.warn('changeActions() is not ready');
+      this.reporting.error(new Error(`changeActions() API is not ready`));
       return;
     }
     this.el = el;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 2135c30..46c7ad6 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -22,6 +22,7 @@
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {appContext} from '../../../services/app-context';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -30,6 +31,8 @@
 export class GrPluginActionContext {
   private popups: PopupPluginApi[] = [];
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(
     public readonly plugin: PluginApi,
     public readonly action: UIActionInfo,
@@ -108,7 +111,9 @@
   call(payload: RequestPayload, onSuccess: (result: unknown) => void) {
     if (!this.action.method) return;
     if (!this.action.__url) {
-      console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
+      this.reporting.error(
+        new Error(`Unable to ${this.action.method} to ${this.action.__key}!`)
+      );
       return;
     }
     this.plugin
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index fe101c5..6fc8f1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -132,7 +132,7 @@
       try {
         url = new URL(url);
       } catch (e) {
-        console.warn(e);
+        this._getReporting().error(e);
         return false;
       }
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 35acdef..cfa6c04 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -79,9 +79,10 @@
     this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
-      console.warn(
-        'Plugin not being loaded from /plugins base path.',
-        'Unable to determine name.'
+      this.report.error(
+        new Error(
+          `Plugin not being loaded from /plugins base path. Unable to determine name.`
+        )
       );
       return this;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index db22ce5..31a709a 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -81,6 +81,8 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly reporting = appContext.reportingService;
+
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -209,7 +211,7 @@
         }
       })
       .catch(err => {
-        console.warn(err);
+        this.reporting.error(err);
         target.disabled = false;
         return;
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index 56ab8c6..b6583d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -88,7 +88,7 @@
   <p
     class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]"
   >
-    No votes.
+    No votes
   </p>
   <table>
     <template
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
index 4bdc1ab..0d44bc8 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     :host {
       display: block;
     }
@@ -30,6 +30,9 @@
       text-decoration: none;
       pointer-events: none;
     }
+    a {
+      color: var(--link-color);
+    }
   </style>
   <span id="output"></span>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
similarity index 68%
rename from polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
rename to polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
index a67fbc4..b2cdba1 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -15,27 +15,30 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-linked-text.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../../test/common-test-setup-karma';
+import './gr-linked-text';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrLinkedText} from './gr-linked-text';
+import {CommentLinks} from '../../../types/common';
+import {queryAndAssert} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-linked-text>
-      <div id="output"></div>
-    </gr-linked-text>
+  <gr-linked-text>
+    <div id="output"></div>
+  </gr-linked-text>
 `);
 
 suite('gr-linked-text tests', () => {
-  let element;
+  let element: GrLinkedText;
 
-  let originalCanonicalPath;
+  let originalCanonicalPath: string | undefined;
 
   setup(() => {
     originalCanonicalPath = window.CANONICAL_PATH;
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrLinkedText;
 
-    sinon.stub(GerritNav, 'mapCommentlinks').value( x => x);
+    sinon.stub(GerritNav, 'mapCommentlinks').value((x: CommentLinks) => x);
     element.config = {
       ph: {
         match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
@@ -86,7 +89,8 @@
     // Regular inline link.
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     element.content = url;
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.rel, 'noopener');
     assert.equal(linkEl.href, url);
@@ -97,14 +101,16 @@
     // "Issue/Bug" pattern.
     element.content = 'Issue 3650';
 
-    let linkEl = element.$.output.childNodes[0];
+    let linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.href, url);
     assert.equal(linkEl.textContent, 'Issue 3650');
 
     element.content = 'Bug 3650';
-    linkEl = element.$.output.childNodes[0];
+    linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.rel, 'noopener');
     assert.equal(linkEl.href, url);
@@ -115,8 +121,9 @@
     // Pattern starts with the same prefix (`http`) as the url.
     element.content = 'httpexample 3650';
 
-    assert.equal(element.$.output.childNodes.length, 1);
-    const linkEl = element.$.output.childNodes[0];
+    assert.equal(queryAndAssert(element, '#output').childNodes.length, 1);
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.href, url);
@@ -129,8 +136,9 @@
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
 
-    const textNode = element.$.output.childNodes[0];
-    const linkEl = element.$.output.childNodes[1];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.equal(textNode.textContent, prefix);
     const url = '/q/' + changeID;
     assert.isFalse(linkEl.hasAttribute('target'));
@@ -147,8 +155,9 @@
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
 
-    const textNode = element.$.output.childNodes[0];
-    const linkEl = element.$.output.childNodes[1];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.equal(textNode.textContent, prefix);
     const url = '/r/q/' + changeID;
     assert.isFalse(linkEl.hasAttribute('target'));
@@ -159,17 +168,23 @@
 
   test('Multiple matches', () => {
     element.content = 'Issue 3650\nIssue 3450';
-    const linkEl1 = element.$.output.childNodes[0];
-    const linkEl2 = element.$.output.childNodes[2];
+    const linkEl1 = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
+    const linkEl2 = queryAndAssert(element, '#output')
+      .childNodes[2] as HTMLAnchorElement;
 
     assert.equal(linkEl1.target, '_blank');
-    assert.equal(linkEl1.href,
-        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
+    assert.equal(
+      linkEl1.href,
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
+    );
     assert.equal(linkEl1.textContent, 'Issue 3650');
 
     assert.equal(linkEl2.target, '_blank');
-    assert.equal(linkEl2.href,
-        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
+    assert.equal(
+      linkEl2.href,
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
+    );
     assert.equal(linkEl2.textContent, 'Issue 3450');
   });
 
@@ -186,9 +201,11 @@
 
     element.content = prefix + changeID + bug;
 
-    const textNode = element.$.output.childNodes[0];
-    const changeLinkEl = element.$.output.childNodes[1];
-    const bugLinkEl = element.$.output.childNodes[2];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const changeLinkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
+    const bugLinkEl = queryAndAssert(element, '#output')
+      .childNodes[2] as HTMLAnchorElement;
 
     assert.equal(textNode.textContent, prefix);
 
@@ -203,15 +220,19 @@
 
   test('html field in link config', () => {
     element.content = 'google:do a barrel roll';
-    const linkEl = element.$.output.childNodes[0];
-    assert.equal(linkEl.getAttribute('href'),
-        'https://google.com/search?q=do a barrel roll');
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.equal(
+      linkEl.getAttribute('href'),
+      'https://google.com/search?q=do a barrel roll'
+    );
     assert.equal(linkEl.textContent, 'do a barrel roll');
   });
 
   test('removing hash from links', () => {
     element.content = 'hash:foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -220,7 +241,8 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'test foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -229,7 +251,8 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'a test foo';
-    const linkEl = element.$.output.childNodes[1];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -238,94 +261,119 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'hash:foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
   test('disabled config', () => {
     element.content = 'foo:baz';
-    assert.equal(element.$.output.innerHTML, 'foo:baz');
+    assert.equal(queryAndAssert(element, '#output').innerHTML, 'foo:baz');
   });
 
   test('R=email labels link correctly', () => {
     element.removeZeroWidthSpace = true;
     element.content = 'R=\u200Btest@google.com';
-    assert.equal(element.$.output.textContent, 'R=test@google.com');
-    assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
+    assert.equal(
+      queryAndAssert(element, '#output').textContent,
+      'R=test@google.com'
+    );
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.match(/(R=<a)/g)!.length,
+      1
+    );
   });
 
   test('CC=email labels link correctly', () => {
     element.removeZeroWidthSpace = true;
     element.content = 'CC=\u200Btest@google.com';
-    assert.equal(element.$.output.textContent, 'CC=test@google.com');
-    assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
+    assert.equal(
+      queryAndAssert(element, '#output').textContent,
+      'CC=test@google.com'
+    );
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.match(/(CC=<a)/g)!.length,
+      1
+    );
   });
 
   test('only {http,https,mailto} protocols are linkified', () => {
     element.content = 'xx mailto:test@google.com yy';
-    let links = element.$.output.querySelectorAll('a');
+    let links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx http://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx https://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
   test('links without leading whitespace are linkified', () => {
     element.content = 'xx abcmailto:test@google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
-    let links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx abc'
+    );
+    let links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx defhttp://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx def'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx qwehttps://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx qwe'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     // Non-latin character
     element.content = 'xx абвhttps://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx абв'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
@@ -341,11 +389,13 @@
       },
     };
     element.content = '- B: 123, 45';
-    const links = element.root.querySelectorAll('a');
+    const links = element.root!.querySelectorAll('a');
 
     assert.equal(links.length, 2);
-    assert.equal(element.shadowRoot
-        .querySelector('span').textContent, '- B: 123, 45');
+    assert.equal(
+      queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
+      '- B: 123, 45'
+    );
 
     assert.equal(links[0].href, 'ftp://foo/123');
     assert.equal(links[0].textContent, '123');
@@ -362,4 +412,3 @@
     assert.isTrue(contentConfigStub.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 852f0b6..28bc229 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -1443,6 +1443,10 @@
     this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
   }
 
+  invalidateAccountsDetailCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
+  }
+
   getGroups(filter: string, groupsPerPage: number, offset?: number) {
     const url = this._getGroupsUrl(filter, groupsPerPage, offset);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
similarity index 75%
rename from polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
rename to polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
index c697850..245d7df 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
@@ -15,41 +15,42 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-select.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../../test/common-test-setup-karma';
+import './gr-select';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrSelect} from './gr-select';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-select>
-      <select>
-        <option value="1">One</option>
-        <option value="2">Two</option>
-        <option value="3">Three</option>
-      </select>
-    </gr-select>
+  <gr-select>
+    <select>
+      <option value="1">One</option>
+      <option value="2">Two</option>
+      <option value="3">Three</option>
+    </select>
+  </gr-select>
 `);
 
 const noOptionsFixture = fixtureFromTemplate(html`
-<gr-select>
-      <select>
-      </select>
-    </gr-select>
+  <gr-select>
+    <select></select>
+  </gr-select>
 `);
 
 suite('gr-select tests', () => {
-  let element;
+  let element: GrSelect;
 
   setup(() => {
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrSelect;
   });
 
   test('bindValue must be set to the first option value', () => {
     assert.equal(element.bindValue, '1');
+    assert.equal(element.nativeSelect.value, '1');
   });
 
   test('value of 0 should still trigger value updates', () => {
-    element.bindValue = 0;
-    assert.equal(element.nativeSelect.value, 0);
+    element.bindValue = '0';
+    assert.equal(element.nativeSelect.value, '');
   });
 
   test('bidirectional binding property-to-attribute', () => {
@@ -82,9 +83,11 @@
     // Now change the value.
     element.nativeSelect.value = '3';
     element.dispatchEvent(
-        new CustomEvent('change', {
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('change', {
+        composed: true,
+        bubbles: true,
+      })
+    );
 
     // It should be updated.
     assert.equal(element.nativeSelect.value, '3');
@@ -93,10 +96,10 @@
   });
 
   suite('gr-select no options tests', () => {
-    let element;
+    let element: GrSelect;
 
     setup(() => {
-      element = noOptionsFixture.instantiate();
+      element = noOptionsFixture.instantiate() as GrSelect;
     });
 
     test('bindValue must not be changed', () => {
@@ -104,4 +107,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index 56d01d2..b10c43b 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -27,7 +27,7 @@
 }
 
 @customElement('gr-shell-command')
-class GrShellCommand extends PolymerElement {
+export class GrShellCommand extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 b3d1578..a747ac4 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -227,11 +227,13 @@
     this._setEmoji(this.$.emojiSuggestions.getCurrentText());
   }
 
-  _handleEnterByKey(e: KeyboardEvent) {
+  _handleEnterByKey(e: CustomEvent<{keyboardEvent: KeyboardEvent}>) {
     // Enter should have newline behavior if the picker is closed or if the user
-    // has only typed ':'.
+    // has only typed ':'. Also make sure that shortcuts aren't clobbered.
     if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
-      this.indent(e);
+      if (!e.detail.keyboardEvent.metaKey && !e.detail.keyboardEvent.ctrlKey) {
+        this.indent(e);
+      }
       return;
     }
 
@@ -402,7 +404,7 @@
     );
   }
 
-  private indent(e: KeyboardEvent): void {
+  private indent(e: CustomEvent<{keyboardEvent: KeyboardEvent}>): void {
     if (!document.queryCommandSupported('insertText')) {
       return;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
index 1747fce..7c2f209 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
@@ -230,6 +230,26 @@
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
   });
 
+  test('ctrl+enter and meta+enter do not indent', async () => {
+    const indentCommand = sinon.stub(document, 'execCommand');
+    element.$.textarea.value = '    a';
+    element._handleEnterByKey(
+        new CustomEvent('keydown', {
+          detail: {keyboardEvent: {keyCode: 13, ctrlKey: true}},
+        })
+    );
+    await flush();
+    assert.isTrue(indentCommand.notCalled);
+
+    element._handleEnterByKey(
+        new CustomEvent('keydown', {
+          detail: {keyboardEvent: {keyCode: 13, metaKey: true}},
+        })
+    );
+    await flush();
+    assert.isTrue(indentCommand.notCalled);
+  });
+
   test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
     const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
     element.$.emojiSuggestions.dispatchEvent(
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index ffee627..422667a4 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -25,9 +25,10 @@
 import '../scripts/bundled-polymer';
 import '../elements/diff/gr-diff/gr-diff';
 import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
-import {initDiffAppContext} from './gr-diff-app-context-init';
-import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
 import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
+import {GrDiffCursor} from '../elements/diff/gr-diff-cursor/gr-diff-cursor';
+import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
+import {initDiffAppContext} from './gr-diff-app-context-init';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
@@ -35,6 +36,7 @@
 // Setup global variables for existing usages of this component
 window.grdiff = {
   GrAnnotation,
+  GrDiffCursor,
   TokenHighlightLayer,
 };
 
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 9036871..312e78d 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -48,7 +48,7 @@
 export function updateState(change?: ParsedChangeInfo) {
   const current = privateState$.getValue();
   // We want to make it easy for subscribers to react to change changes, so we
-  // are explicitly emitting and additional `undefined` when the change number
+  // are explicitly emitting an additional `undefined` when the change number
   // changes. So if you are subscribed to the latestPatchsetNumber for example,
   // then you can rely on emissions even if the old and the new change have the
   // same latestPatchsetNumber.
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index 1c6fc4c..0b9a1f2 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {routerChangeNum$} from '../router/router-model';
-import {updateState} from './change-model';
+import {change$, updateState} from './change-model';
 import {ParsedChangeInfo} from '../../types/types';
 import {appContext} from '../app-context';
 import {ChangeInfo} from '../../types/common';
@@ -25,6 +25,8 @@
 } from '../../utils/patch-set-util';
 
 export class ChangeService {
+  private change?: ParsedChangeInfo;
+
   private readonly restApiService = appContext.restApiService;
 
   constructor() {
@@ -34,6 +36,9 @@
     routerChangeNum$.subscribe(changeNum => {
       if (!changeNum) updateState(undefined);
     });
+    change$.subscribe(change => {
+      this.change = change;
+    });
   }
 
   /**
@@ -48,6 +53,15 @@
   }
 
   /**
+   * Typically you would just subscribe to change$ yourself to get updates. But
+   * sometimes it is nice to also be able to get the current ChangeInfo on
+   * demand. So here it is for your convenience.
+   */
+  getChange() {
+    return this.change;
+  }
+
+  /**
    * Check whether there is no newer patch than the latest patch that was
    * available when this change was loaded.
    *
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 60cb780..5d3da42 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -21,7 +21,6 @@
   Category,
   CheckResult as CheckResultApi,
   CheckRun as CheckRunApi,
-  ChecksApiConfig,
   Link,
   LinkIcon,
   RunStatus,
@@ -32,6 +31,17 @@
 import {AttemptDetail, createAttemptMap} from './checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 
+/**
+ * The checks model maintains the state of checks for two patchsets: the latest
+ * and (if different) also for the one selected in the checks tab. So we need
+ * the distinction in a lot of places for checks about whether the code affects
+ * the checks data of the LATEST or the SELECTED patchset.
+ */
+export enum ChecksPatchset {
+  LATEST = 'LATEST',
+  SELECTED = 'SELECTED',
+}
+
 export interface CheckResult extends CheckResultApi {
   /**
    * Internally we want to uniquely identify a run with an id, for example when
@@ -75,21 +85,33 @@
   errorMessage?: string;
   /** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
   loginCallback?: () => void;
-  config?: ChecksApiConfig;
   runs: CheckRun[];
   actions: Action[];
   links: Link[];
 }
 
 interface ChecksState {
-  patchsetNumber?: PatchSetNumber;
-  providerNameToState: {
+  /**
+   * This is the patchset number selected by the user. The *latest* patchset
+   * can be picked up from the change model.
+   */
+  patchsetNumberSelected?: PatchSetNumber;
+  /** Checks data for the latest patchset. */
+  pluginStateLatest: {
+    [name: string]: ChecksProviderState;
+  };
+  /**
+   * Checks data for the selected patchset. Note that `checksSelected$` below
+   * falls back to the data for the latest patchset, if no patchset is selected.
+   */
+  pluginStateSelected: {
     [name: string]: ChecksProviderState;
   };
 }
 
 const initialState: ChecksState = {
-  providerNameToState: {},
+  pluginStateLatest: {},
+  pluginStateSelected: {},
 };
 
 const privateState$ = new BehaviorSubject(initialState);
@@ -97,29 +119,45 @@
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const checksState$: Observable<ChecksState> = privateState$;
 
-export const checksPatchsetNumber$ = checksState$.pipe(
-  map(state => state.patchsetNumber),
+export const checksSelectedPatchsetNumber$ = checksState$.pipe(
+  map(state => state.patchsetNumberSelected),
   distinctUntilChanged()
 );
 
-export const checksProviderState$ = checksState$.pipe(
-  map(state => state.providerNameToState),
+export const checksLatest$ = checksState$.pipe(
+  map(state => state.pluginStateLatest),
   distinctUntilChanged()
 );
 
-export const aPluginHasRegistered$ = checksProviderState$.pipe(
+export const checksSelected$ = checksState$.pipe(
+  map(state =>
+    state.patchsetNumberSelected
+      ? state.pluginStateSelected
+      : state.pluginStateLatest
+  ),
+  distinctUntilChanged()
+);
+
+export const aPluginHasRegistered$ = checksLatest$.pipe(
   map(state => Object.keys(state).length > 0),
   distinctUntilChanged()
 );
 
-export const someProvidersAreLoading$ = checksProviderState$.pipe(
+export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
   map(state =>
     Object.values(state).some(providerState => providerState.loading)
   ),
   distinctUntilChanged()
 );
 
-export const errorMessage$ = checksProviderState$.pipe(
+export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
+  map(state =>
+    Object.values(state).some(providerState => providerState.loading)
+  ),
+  distinctUntilChanged()
+);
+
+export const errorMessageLatest$ = checksLatest$.pipe(
   map(
     state =>
       Object.values(state).find(
@@ -129,7 +167,7 @@
   distinctUntilChanged()
 );
 
-export const loginCallback$ = checksProviderState$.pipe(
+export const loginCallbackLatest$ = checksLatest$.pipe(
   map(
     state =>
       Object.values(state).find(
@@ -139,7 +177,7 @@
   distinctUntilChanged()
 );
 
-export const allActions$ = checksProviderState$.pipe(
+export const topLevelActionsSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allActions: Action[], providerState: ChecksProviderState) => [
@@ -151,7 +189,7 @@
   )
 );
 
-export const allLinks$ = checksProviderState$.pipe(
+export const topLevelLinksSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allActions: Link[], providerState: ChecksProviderState) => [
@@ -163,7 +201,7 @@
   )
 );
 
-export const allRuns$ = checksProviderState$.pipe(
+export const allRunsLatestPatchset$ = checksLatest$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allRuns: CheckRun[], providerState: ChecksProviderState) => [
@@ -175,11 +213,23 @@
   )
 );
 
-export const allRunsLatest$ = allRuns$.pipe(
+export const allRunsSelectedPatchset$ = checksSelected$.pipe(
+  map(state =>
+    Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    )
+  )
+);
+
+export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
   map(runs => runs.filter(run => run.isLatestAttempt))
 );
 
-export const checkToPluginMap$ = checksProviderState$.pipe(
+export const checkToPluginMap$ = checksLatest$.pipe(
   map(state => {
     const map = new Map<string, string>();
     for (const [pluginName, providerState] of Object.entries(state)) {
@@ -191,7 +241,7 @@
   })
 );
 
-export const allResults$ = checksProviderState$.pipe(
+export const allResultsSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state)
       .reduce(
@@ -211,16 +261,12 @@
 
 // Must only be used by the checks service or whatever is in control of this
 // model.
-export function updateStateSetProvider(
-  pluginName: string,
-  config?: ChecksApiConfig
-) {
+export function updateStateSetProvider(pluginName: string) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
+  nextState.pluginStateLatest = {...nextState.pluginStateLatest};
+  nextState.pluginStateLatest[pluginName] = {
     pluginName,
     loading: false,
-    config,
     runs: [],
     actions: [],
     links: [],
@@ -253,6 +299,7 @@
       internalResultId: 'f0r1',
       category: Category.ERROR,
       summary: 'Running the mighty test has failed by crashing.',
+      message: 'Btw, 1 is also not equal to 3. Did you know?',
       actions: [
         {
           name: 'Ignore',
@@ -276,8 +323,16 @@
       ],
       tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
       links: [
-        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
         {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {
+          primary: true,
+          url: 'https://google.com',
+          icon: LinkIcon.DOWNLOAD_MOBILE,
+        },
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
         {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
         {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
         {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
@@ -290,6 +345,7 @@
 export const fakeRun1: CheckRun = {
   internalRunId: 'f1',
   checkName: 'FAKE Super Check',
+  patchset: 1,
   labelName: 'Verified',
   isSingleAttempt: true,
   isLatestAttempt: true,
@@ -302,6 +358,26 @@
       message: `There is a lot to be said. A lot. I say, a lot.\n
                 So please keep reading.`,
       tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 10,
+            start_character: 0,
+            end_line: 10,
+            end_character: 0,
+          },
+        },
+        {
+          path: 'polygerrit-ui/app/api/checks.ts',
+          range: {
+            start_line: 5,
+            start_character: 0,
+            end_line: 7,
+            end_character: 0,
+          },
+        },
+      ],
       links: [
         {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
         {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
@@ -311,6 +387,18 @@
           icon: LinkIcon.DOWNLOAD_MOBILE,
         },
         {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'look at this',
+          icon: LinkIcon.IMAGE,
+        },
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'not at this',
+          icon: LinkIcon.IMAGE,
+        },
       ],
     },
   ],
@@ -472,32 +560,82 @@
   {
     url: 'https://www.google.com',
     primary: true,
-    tooltip: 'Tooltip for Bug Report Fake Link',
+    tooltip: 'Fake Bug Report 1',
     icon: LinkIcon.REPORT_BUG,
   },
   {
     url: 'https://www.google.com',
-    primary: false,
-    tooltip: 'Tooltip for External Fake Link',
+    primary: true,
+    tooltip: 'Fake Bug Report 2',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Link 1',
     icon: LinkIcon.EXTERNAL,
   },
+  {
+    url: 'https://www.google.com',
+    primary: false,
+    tooltip: 'Fake Link 2',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Code Link',
+    icon: LinkIcon.CODE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Image Link',
+    icon: LinkIcon.IMAGE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Help Link',
+    icon: LinkIcon.HELP_PAGE,
+  },
 ];
 
-export function updateStateSetLoading(pluginName: string) {
+export function getPluginState(
+  state: ChecksState,
+  patchset: ChecksPatchset = ChecksPatchset.LATEST
+) {
+  if (patchset === ChecksPatchset.LATEST) {
+    state.pluginStateLatest = {...state.pluginStateLatest};
+    return state.pluginStateLatest;
+  } else {
+    state.pluginStateSelected = {...state.pluginStateSelected};
+    return state.pluginStateSelected;
+  }
+}
+
+export function updateStateSetLoading(
+  pluginName: string,
+  patchset: ChecksPatchset
+) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: true,
   };
   privateState$.next(nextState);
 }
 
-export function updateStateSetError(pluginName: string, errorMessage: string) {
+export function updateStateSetError(
+  pluginName: string,
+  errorMessage: string,
+  patchset: ChecksPatchset
+) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
     errorMessage,
     loginCallback: undefined,
@@ -509,12 +647,13 @@
 
 export function updateStateSetNotLoggedIn(
   pluginName: string,
-  loginCallback: () => void
+  loginCallback: () => void,
+  patchset: ChecksPatchset
 ) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
     errorMessage: undefined,
     loginCallback,
@@ -528,7 +667,8 @@
   pluginName: string,
   runs: CheckRunApi[],
   actions: Action[] = [],
-  links: Link[] = []
+  links: Link[] = [],
+  patchset: ChecksPatchset
 ) {
   const attemptMap = createAttemptMap(runs);
   for (const attemptInfo of attemptMap.values()) {
@@ -537,9 +677,9 @@
     attemptInfo.attempts.sort((a, b) => (b.attempt ?? -1) - (a.attempt ?? -1));
   }
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
     errorMessage: undefined,
     loginCallback: undefined,
@@ -569,6 +709,6 @@
 
 export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
   const nextState = {...privateState$.getValue()};
-  nextState.patchsetNumber = patchsetNumber;
+  nextState.patchsetNumberSelected = patchsetNumber;
   privateState$.next(nextState);
 }
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 48d61e2..e1d435b 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -32,7 +32,8 @@
 } from '../../api/checks';
 import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
 import {
-  checksPatchsetNumber$,
+  ChecksPatchset,
+  checksSelectedPatchsetNumber$,
   checkToPluginMap$,
   updateStateSetError,
   updateStateSetLoading,
@@ -55,6 +56,7 @@
 import {getShaByPatchNum} from '../../utils/patch-set-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
+import {routerPatchNum$} from '../router/router-model';
 
 export class ChecksService {
   private readonly providers: {[name: string]: ChecksProvider} = {};
@@ -63,15 +65,26 @@
 
   private checkToPluginMap = new Map<string, string>();
 
+  private latestPatchNum?: PatchSetNumber;
+
   private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
 
   constructor(readonly reporting: ReportingService) {
     checkToPluginMap$.subscribe(map => {
       this.checkToPluginMap = map;
     });
-    latestPatchNum$.subscribe(num => {
-      updateStateSetPatchset(num);
-    });
+    combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
+      ([routerPs, latestPs]) => {
+        this.latestPatchNum = latestPs;
+        if (latestPs === undefined) {
+          this.setPatchset(undefined);
+        } else if (typeof routerPs === 'number') {
+          this.setPatchset(routerPs);
+        } else {
+          this.setPatchset(latestPs);
+        }
+      }
+    );
     document.addEventListener('visibilitychange', () => {
       this.documentVisibilityChange$.next(undefined);
     });
@@ -80,8 +93,8 @@
     });
   }
 
-  setPatchset(num: PatchSetNumber) {
-    updateStateSetPatchset(num);
+  setPatchset(num?: PatchSetNumber) {
+    updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
   }
 
   reload(pluginName: string) {
@@ -105,7 +118,16 @@
   ) {
     this.providers[pluginName] = provider;
     this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
-    updateStateSetProvider(pluginName, config);
+    updateStateSetProvider(pluginName);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+  }
+
+  initFetchingOfData(
+    pluginName: string,
+    config: ChecksApiConfig,
+    patchset: ChecksPatchset
+  ) {
     const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
     // Various events should trigger fetching checks from the provider:
     // 1. Change number and patchset number changes.
@@ -114,7 +136,9 @@
     // 4. A hidden Gerrit tab becoming visible.
     combineLatest([
       changeNum$,
-      checksPatchsetNumber$,
+      patchset === ChecksPatchset.LATEST
+        ? latestPatchNum$
+        : checksSelectedPatchsetNumber$,
       this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
       timer(0, pollIntervalMs),
       this.documentVisibilityChange$,
@@ -125,27 +149,13 @@
         withLatestFrom(change$),
         switchMap(
           ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
-            if (
-              !change ||
-              !changeNum ||
-              !patchNum ||
-              typeof patchNum !== 'number'
-            ) {
-              return of({
-                responseCode: ResponseCode.OK,
-                runs: [],
-              });
-            }
+            if (!change || !changeNum || !patchNum) return of(this.empty());
+            if (typeof patchNum !== 'number') return of(this.empty());
             assertIsDefined(change.revisions, 'change.revisions');
             const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
             // Sometimes patchNum is updated earlier than change, so change
             // revisions don't have patchNum yet
-            if (!patchsetSha) {
-              return of({
-                responseCode: ResponseCode.OK,
-                runs: [],
-              });
-            }
+            if (!patchsetSha) return of(this.empty());
             const data: ChangeData = {
               changeNumber: changeNum,
               patchsetNumber: patchNum,
@@ -154,7 +164,7 @@
               commmitMessage: getCurrentRevision(change)?.commit?.message,
               changeInfo: change,
             };
-            return this.fetchResults(pluginName, data);
+            return this.fetchResults(pluginName, data, patchset);
           }
         ),
         catchError(e => {
@@ -169,24 +179,36 @@
         switch (response.responseCode) {
           case ResponseCode.ERROR:
             assertIsDefined(response.errorMessage, 'errorMessage');
-            updateStateSetError(pluginName, response.errorMessage);
+            updateStateSetError(pluginName, response.errorMessage, patchset);
             break;
           case ResponseCode.NOT_LOGGED_IN:
             assertIsDefined(response.loginCallback, 'loginCallback');
-            updateStateSetNotLoggedIn(pluginName, response.loginCallback);
+            updateStateSetNotLoggedIn(
+              pluginName,
+              response.loginCallback,
+              patchset
+            );
             break;
           case ResponseCode.OK:
             updateStateSetResults(
               pluginName,
               response.runs ?? [],
               response.actions ?? [],
-              response.links ?? []
+              response.links ?? [],
+              patchset
             );
             break;
         }
       });
   }
 
+  private empty(): FetchResponse {
+    return {
+      responseCode: ResponseCode.OK,
+      runs: [],
+    };
+  }
+
   private createErrorResponse(
     pluginName: string,
     message: string
@@ -199,9 +221,10 @@
 
   private fetchResults(
     pluginName: string,
-    data: ChangeData
+    data: ChangeData,
+    patchset: ChecksPatchset
   ): Observable<FetchResponse> {
-    updateStateSetLoading(pluginName);
+    updateStateSetLoading(pluginName, patchset);
     const timer = this.reporting.getTimer('ChecksPluginFetch');
     const fetchPromise = this.providers[pluginName]
       .fetch(data)
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 981a1f6..9381f30 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -43,6 +43,8 @@
       return 'help-outline';
     case LinkIcon.REPORT_BUG:
       return 'bug';
+    case LinkIcon.CODE:
+      return 'code';
     default:
       // We don't throw an assertion error here, because plugins don't have to
       // be written in TypeScript, so we may encounter arbitrary strings for
@@ -99,7 +101,7 @@
   }
 }
 
-enum PRIMARY_STATUS_ACTIONS {
+export enum PRIMARY_STATUS_ACTIONS {
   RERUN = 'rerun',
   RUN = 'run',
   CANCEL = 'cancel',
@@ -129,7 +131,8 @@
   }
 }
 
-export function primaryRunAction(run: CheckRun): Action | undefined {
+export function primaryRunAction(run?: CheckRun): Action | undefined {
+  if (!run) return undefined;
   return runActions(run).filter(
     action => !action.disabled && action.name === primaryActionName(run.status)
   )[0];
@@ -238,9 +241,10 @@
 
 export function fireActionTriggered(
   target: EventTarget,
-  action: Action,
+  action?: Action,
   run?: CheckRun
 ) {
+  if (!action) return;
   target.dispatchEvent(
     new CustomEvent('action-triggered', {
       detail: {action, run},
@@ -310,14 +314,19 @@
   };
 }
 
-export function primaryLink(result?: CheckResultApi): Link | undefined {
-  const links = result?.links ?? [];
-  return links.find(link => link.primary);
+function allPrimaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.links ?? []).filter(link => link.primary);
 }
 
-export function otherLinks(result?: CheckResultApi): Link[] {
-  const primary = primaryLink(result);
-  const links = result?.links ?? [];
-  // Just filter the one primary link, not all primary links.
-  return links.filter(link => link !== primary);
+export function firstPrimaryLink(result?: CheckResultApi): Link | undefined {
+  return allPrimaryLinks(result).find(link => link.icon === LinkIcon.EXTERNAL);
+}
+
+export function otherPrimaryLinks(result?: CheckResultApi): Link[] {
+  const first = firstPrimaryLink(result);
+  return allPrimaryLinks(result).filter(link => link !== first);
+}
+
+export function secondaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.links ?? []).filter(link => !link.primary);
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 509a140..ac072f3 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -18,7 +18,12 @@
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution, LifeCycle, Timing} from '../../constants/reporting';
+import {
+  Execution,
+  Interaction,
+  LifeCycle,
+  Timing,
+} from '../../constants/reporting';
 
 export type EventValue = string | number | {error?: Error};
 
@@ -43,7 +48,7 @@
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): void;
-  changeDisplayed(): void;
+  changeDisplayed(eventDetails?: EventDetails): void;
   changeFullyLoaded(): void;
   diffViewDisplayed(): void;
   diffViewFullyLoaded(): void;
@@ -104,7 +109,10 @@
     object: string,
     method: string
   ): void;
-  reportInteraction(eventName: string, details?: EventDetails): void;
+  reportInteraction(
+    eventName: string | Interaction,
+    details?: EventDetails
+  ): void;
   /**
    * A draft interaction was started. Update the time-between-draft-actions
    * timer.
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 4e6ece2..0b93f07 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -21,7 +21,12 @@
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution, LifeCycle, Timing} from '../../constants/reporting';
+import {
+  Execution,
+  Interaction,
+  LifeCycle,
+  Timing,
+} from '../../constants/reporting';
 
 // Latency reporting constants.
 
@@ -508,11 +513,12 @@
     }
   }
 
-  changeDisplayed() {
+  changeDisplayed(eventDetails?: EventDetails) {
+    eventDetails = {...eventDetails, ...this._pageLoadDetails()};
     if (hasOwnProperty(this._baselines, Timing.STARTUP_CHANGE_DISPLAYED)) {
-      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, eventDetails);
     } else {
-      this.timeEnd(Timing.CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.CHANGE_DISPLAYED, eventDetails);
     }
   }
 
@@ -772,7 +778,7 @@
     );
   }
 
-  reportInteraction(eventName: string, details: EventDetails) {
+  reportInteraction(eventName: string | Interaction, details: EventDetails) {
     this.reporter(
       INTERACTION.TYPE,
       INTERACTION.CATEGORY.DEFAULT,
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index c180439..24c27e0 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -17,7 +17,7 @@
 import {ReportingService, Timer} from './gr-reporting';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution} from '../../constants/reporting';
+import {Execution, Interaction} from '../../constants/reporting';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -72,7 +72,10 @@
     log(`trackApi '${plugin}', ${object}, ${method}`);
   },
   reportExtension: () => {},
-  reportInteraction: (eventName: string, details?: EventDetails) => {
+  reportInteraction: (
+    eventName: string | Interaction,
+    details?: EventDetails
+  ) => {
     log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
   },
   reportLifeCycle: () => {},
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 3a890d9..cd3fab3 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -534,6 +534,7 @@
   invalidateGroupsCache(): void;
   invalidateReposCache(): void;
   invalidateAccountsCache(): void;
+  invalidateAccountsDetailCache(): void;
   removeFromAttentionSet(
     changeNum: NumericChangeId,
     user: AccountId,
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
index 2354f65..0c9f38d 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.ts
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -55,9 +55,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index d1fcdc9..e0a7a28 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -202,9 +202,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
index 3d07d2e..67a6963 100644
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
@@ -55,9 +55,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
index 57c8d78..145f0d5 100644
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
@@ -22,6 +22,15 @@
 
 const $_documentContainer = document.createElement('template');
 
+/*
+  These are shared styles for change-view-integration endpoints.
+  All plugins that registered that endpoint should include this in
+  the component to have a consistent UX:
+
+  <style include="gr-change-view-integration-shared-styles"></style>
+
+  And use those defined class to apply these styles.
+*/
 $_documentContainer.innerHTML = `<dom-module id="gr-change-view-integration-shared-styles">
   <template>
     <style include="shared-styles">
@@ -61,18 +70,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  This is shared styles for change-view-integration endpoints.
-  All plugins that registered that endpoint should include this in
-  the component to have a consistent UX:
-
-  <style include="gr-change-view-integration-shared-styles"></style>
-
-  And use those defined class to apply these styles.
-*/
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index 3284ad5..f58a02c 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -124,9 +124,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index e9a79c1f..d46f136 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -79,9 +79,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index 9010b2d..8c29b85 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -72,9 +72,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
index 41ee952..5aab0dc 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.ts
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -42,9 +42,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts
index 52fdc67..09d1161 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.ts
+++ b/polygerrit-ui/app/styles/gr-table-styles.ts
@@ -116,9 +116,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index b50aee6..cb8b0be8 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -48,9 +48,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 1bee660..287cf68 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -304,9 +304,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 3971fa5..a3df94c 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -411,6 +411,7 @@
   invalidateAccountsCache(): void {},
   invalidateGroupsCache(): void {},
   invalidateReposCache(): void {},
+  invalidateAccountsDetailCache(): void {},
   probePath(): Promise<boolean> {
     return Promise.resolve(true);
   },
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 65c72f9..8e89525 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -18,7 +18,7 @@
 import {PatchSetNum, UrlEncodedCommentId} from './common';
 import {UIComment} from '../utils/comment-util';
 import {FetchRequest} from './types';
-import {MovedLinkClickedEventDetail} from '../api/diff';
+import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
@@ -55,6 +55,10 @@
     'editable-content-save': EditableContentSaveEvent;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
+    'line-number-mouse-enter': LineNumberEvent;
+    'line-number-mouse-leave': LineNumberEvent;
+    'line-cursor-moved-in': LineNumberEvent;
+    'line-cursor-moved-out': LineNumberEvent;
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
     'close-fix-preview': CloseFixPreviewEvent;
@@ -124,6 +128,8 @@
 
 export type MovedLinkClickedEvent = CustomEvent<MovedLinkClickedEventDetail>;
 
+export type LineNumberEvent = CustomEvent<LineNumberEventDetail>;
+
 export interface NetworkErrorEventDetail {
   error: Error;
 }
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index dd91f7b..380d06c 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -24,6 +24,7 @@
   RelatedChangeAndCommitInfo,
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
+import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -134,28 +135,27 @@
   return change?.status === ChangeStatus.NEW;
 }
 
-// TODO(TS): use enum ChangeStates in gr-change-status
 export function changeStatuses(
   change: ChangeInfo,
   opt_options?: ChangeStatusesOptions
-) {
+): ChangeStates[] {
   const states = [];
   if (change.status === ChangeStatus.MERGED) {
-    states.push('Merged');
+    states.push(ChangeStates.MERGED);
   } else if (change.status === ChangeStatus.ABANDONED) {
-    states.push('Abandoned');
+    states.push(ChangeStates.ABANDONED);
   } else if (
     change.mergeable === false ||
     (opt_options && opt_options.mergeable === false)
   ) {
     // 'mergeable' prop may not always exist (@see Issue 6819)
-    states.push('Merge Conflict');
+    states.push(ChangeStates.MERGE_CONFLICT);
   }
   if (change.work_in_progress) {
-    states.push('WIP');
+    states.push(ChangeStates.WIP);
   }
   if (change.is_private) {
-    states.push('Private');
+    states.push(ChangeStates.PRIVATE);
   }
 
   // If there are any pre-defined statuses, only return those. Otherwise,
@@ -166,21 +166,24 @@
 
   // If no missing requirements, either active or ready to submit.
   if (change.submittable && opt_options.submitEnabled) {
-    states.push('Ready to submit');
+    states.push(ChangeStates.READY_TO_SUBMIT);
   } else {
     // Otherwise it is active.
-    states.push('Active');
+    states.push(ChangeStates.ACTIVE);
   }
   return states;
 }
 
-export function isOwner(change?: ChangeInfo, account?: AccountInfo): boolean {
+export function isOwner(
+  change?: ChangeInfo | ParsedChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   return change.owner?._account_id === account._account_id;
 }
 
 export function isReviewer(
-  change?: ChangeInfo,
+  change?: ChangeInfo | ParsedChangeInfo,
   account?: AccountInfo
 ): boolean {
   if (!change || !account) return false;
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
deleted file mode 100644
index f348239..0000000
--- a/polygerrit-ui/app/utils/change-util_test.js
+++ /dev/null
@@ -1,202 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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.
- */
-
-import '../test/common-test-setup-karma.js';
-import {
-  changeBaseURL,
-  changePath,
-  changeStatuses,
-  isRemovableReviewer,
-} from './change-util.js';
-
-suite('change-util tests', () => {
-  let originalCanonicalPath;
-
-  suiteSetup(() => {
-    originalCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = '/r';
-  });
-
-  suiteTeardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('changeBaseURL', () => {
-    assert.deepEqual(
-        changeBaseURL('test/project', '1', '2'),
-        '/r/changes/test%2Fproject~1/revisions/2'
-    );
-  });
-
-  test('changePath', () => {
-    assert.deepEqual(changePath('1'), '/r/c/1');
-  });
-
-  test('Open status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    let statuses = changeStatuses(change);
-    assert.deepEqual(statuses, []);
-
-    change.submittable = false;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Active']);
-
-    // With no missing labels but no submitEnabled option.
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Active']);
-
-    // Without missing labels and enabled submit
-    statuses = changeStatuses(change,
-        {includeDerived: true, submitEnabled: true});
-    assert.deepEqual(statuses, ['Ready to submit']);
-
-    change.mergeable = false;
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Merge Conflict']);
-
-    delete change.mergeable;
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true, mergeable: true, submitEnabled: true});
-    assert.deepEqual(statuses, ['Ready to submit']);
-
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true, mergeable: false});
-    assert.deepEqual(statuses, ['Merge Conflict']);
-  });
-
-  test('Merge conflict', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: false,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merge Conflict']);
-  });
-
-  test('mergeable prop undefined', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, []);
-  });
-
-  test('Merged status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'MERGED',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merged']);
-  });
-
-  test('Abandoned status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'ABANDONED',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Abandoned']);
-  });
-
-  test('Open status with private and wip', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: true,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['WIP', 'Private']);
-  });
-
-  test('Merge conflict with private and wip', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: false,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
-  });
-
-  test('isRemovableReviewer', () => {
-    let change = {
-      removable_reviewers: [{_account_id: 1}],
-    };
-    const reviewer = {_account_id: 1};
-
-    assert.equal(isRemovableReviewer(change, reviewer), true);
-
-    change = {
-      removable_reviewers: [{_account_id: 2}],
-    };
-    assert.equal(isRemovableReviewer(change, reviewer), false);
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
new file mode 100644
index 0000000..8232e56
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -0,0 +1,198 @@
+/**
+ * @license
+ * Copyright (C) 2017 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.
+ */
+
+import {ChangeStatus} from '../constants/constants';
+import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
+import '../test/common-test-setup-karma';
+import {createChange, createRevisions} from '../test/test-data-generators';
+import {
+  AccountId,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+} from '../types/common';
+import {
+  changeBaseURL,
+  changePath,
+  changeStatuses,
+  isRemovableReviewer,
+} from './change-util';
+
+suite('change-util tests', () => {
+  let originalCanonicalPath: string | undefined;
+
+  suiteSetup(() => {
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = '/r';
+  });
+
+  suiteTeardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('changeBaseURL', () => {
+    assert.deepEqual(
+      changeBaseURL('test/project', 1 as NumericChangeId, '2' as PatchSetNum),
+      '/r/changes/test%2Fproject~1/revisions/2'
+    );
+  });
+
+  test('changePath', () => {
+    assert.deepEqual(changePath(1 as NumericChangeId), '/r/c/1');
+  });
+
+  test('Open status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      mergeable: true,
+    };
+    let statuses = changeStatuses(change);
+    assert.deepEqual(statuses, []);
+
+    change.submittable = false;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
+
+    // With no missing labels but no submitEnabled option.
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
+
+    // Without missing labels and enabled submit
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
+
+    change.mergeable = false;
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+
+    change.mergeable = true;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
+
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+  });
+
+  test('Merge conflict', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+  });
+
+  test('mergeable prop undefined', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, []);
+  });
+
+  test('Merged status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.MERGED]);
+  });
+
+  test('Abandoned status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.ABANDONED,
+      mergeable: false,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.ABANDONED]);
+  });
+
+  test('Open status with private and wip', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: true,
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.WIP, ChangeStates.PRIVATE]);
+  });
+
+  test('Merge conflict with private and wip', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [
+      ChangeStates.MERGE_CONFLICT,
+      ChangeStates.WIP,
+      ChangeStates.PRIVATE,
+    ]);
+  });
+
+  test('isRemovableReviewer', () => {
+    let change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      removable_reviewers: [{_account_id: 1 as AccountId}],
+    };
+    const reviewer = {_account_id: 1 as AccountId};
+
+    assert.equal(isRemovableReviewer(change, reviewer), true);
+
+    change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      removable_reviewers: [{_account_id: 2 as AccountId}],
+    };
+    assert.equal(isRemovableReviewer(change, reviewer), false);
+  });
+});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 08b5e49..36c3657 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -90,6 +90,24 @@
   }
 }
 
+function query<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E | undefined {
+  if (!el) return undefined;
+  const root = el.shadowRoot ?? el;
+  return root.querySelector<E>(selector) ?? undefined;
+}
+
+export function queryAndAssert<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E {
+  const found = query<E>(el, selector);
+  if (!found) throw new Error(`selector '${selector}' did not match anything'`);
+  return found;
+}
+
 /**
  * Returns true, if both sets contain the same members.
  */
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
deleted file mode 100644
index 93073fa..0000000
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ /dev/null
@@ -1,211 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {
-  _testOnly_computeWipForPatchSets, computeAllPatchSets,
-  findEditParentPatchNum, findEditParentRevision,
-  getParentIndex, getRevisionByPatchNum,
-  isMergeParent,
-  sortRevisions,
-} from './patch-set-util.js';
-
-suite('gr-patch-set-util tests', () => {
-  test('getRevisionByPatchNum', () => {
-    const revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.deepEqual(getRevisionByPatchNum(revisions, 1), revisions[1]);
-    assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
-    assert.equal(getRevisionByPatchNum(revisions, 3), undefined);
-  });
-
-  test('_computeWipForPatchSets', () => {
-    // Compute patch sets for a given timeline on a change. The initial WIP
-    // property of the change can be true or false. The map of tags by
-    // revision is keyed by patch set number. Each value is a list of change
-    // message tags in the order that they occurred in the timeline. These
-    // indicate actions that modify the WIP property of the change and/or
-    // create new patch sets.
-    //
-    // Returns the actual results with an assertWip method that can be used
-    // to compare against an expected value for a particular patch set.
-    const compute = (initialWip, tagsByRevision) => {
-      const change = {
-        messages: [],
-        work_in_progress: initialWip,
-      };
-      const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
-      for (const rev of revs) {
-        for (const tag of tagsByRevision[rev]) {
-          change.messages.push({
-            tag,
-            _revision_number: rev,
-          });
-        }
-      }
-      let patchNums = revs.map(rev => { return {num: rev}; });
-      patchNums = _testOnly_computeWipForPatchSets(
-          change, patchNums);
-      const actualWipsByRevision = {};
-      for (const patchNum of patchNums) {
-        actualWipsByRevision[patchNum.num] = patchNum.wip;
-      }
-      const verifier = {
-        assertWip(revision, expectedWip) {
-          const patchNum = patchNums.find(patchNum => patchNum.num == revision);
-          if (!patchNum) {
-            assert.fail('revision ' + revision + ' not found');
-          }
-          assert.equal(patchNum.wip, expectedWip,
-              'wip state for ' + revision + ' is ' +
-            patchNum.wip + '; expected ' + expectedWip);
-          return verifier;
-        },
-      };
-      return verifier;
-    };
-
-    compute(false, {1: ['upload']}).assertWip(1, false);
-    compute(true, {1: ['upload']}).assertWip(1, true);
-
-    const setWip = 'autogenerated:gerrit:setWorkInProgress';
-    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
-    const clearWip = 'autogenerated:gerrit:setReadyForReview';
-
-    compute(false, {
-      1: ['upload', setWip],
-      2: ['upload'],
-      3: ['upload', clearWip],
-      4: ['upload', setWip],
-    }).assertWip(1, false) // Change was created with PS1 ready for review
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review after upload
-        .assertWip(4, false); // PS4 was uploaded ready for review
-
-    compute(false, {
-      1: [uploadInWip, null, 'addReviewer'],
-      2: ['upload'],
-      3: ['upload', clearWip, setWip],
-      4: ['upload'],
-      5: ['upload', clearWip],
-      6: [uploadInWip],
-    }).assertWip(1, true) // Change was created in WIP
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review
-        .assertWip(4, true) // PS4 was uploaded during WIP
-        .assertWip(5, false) // PS5 was marked ready for review
-        .assertWip(6, true); // PS6 was uploaded with WIP option
-  });
-
-  test('isMergeParent', () => {
-    assert.isFalse(isMergeParent(1));
-    assert.isFalse(isMergeParent(4321));
-    assert.isFalse(isMergeParent('52'));
-    assert.isFalse(isMergeParent('edit'));
-    assert.isFalse(isMergeParent('PARENT'));
-    assert.isFalse(isMergeParent(0));
-
-    assert.isTrue(isMergeParent(-23));
-    assert.isTrue(isMergeParent(-1));
-    assert.isTrue(isMergeParent('-42'));
-  });
-
-  test('findEditParentRevision', () => {
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.strictEqual(findEditParentRevision(revisions), null);
-
-    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
-    assert.strictEqual(findEditParentRevision(revisions), null);
-
-    revisions = [...revisions, {_number: 3}];
-    assert.deepEqual(findEditParentRevision(revisions), {_number: 3});
-  });
-
-  test('findEditParentPatchNum', () => {
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.equal(findEditParentPatchNum(revisions), -1);
-
-    revisions =
-        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
-    assert.deepEqual(findEditParentPatchNum(revisions), 3);
-  });
-
-  test('sortRevisions', () => {
-    const revisions = [
-      {_number: 0},
-      {_number: 2},
-      {_number: 1},
-    ];
-    const sorted = [
-      {_number: 2},
-      {_number: 1},
-      {_number: 0},
-    ];
-
-    assert.deepEqual(sortRevisions(revisions), sorted);
-
-    // Edit patchset should follow directly after its basePatchNum.
-    revisions.push({_number: 'edit', basePatchNum: 2});
-    sorted.unshift({_number: 'edit', basePatchNum: 2});
-    assert.deepEqual(sortRevisions(revisions), sorted);
-
-    revisions[0].basePatchNum = 0;
-    const edit = sorted.shift();
-    edit.basePatchNum = 0;
-    // Edit patchset should be at index 2.
-    sorted.splice(2, 0, edit);
-    assert.deepEqual(sortRevisions(revisions), sorted);
-  });
-
-  test('getParentIndex', () => {
-    assert.equal(getParentIndex('-13'), 13);
-    assert.equal(getParentIndex(-4), 4);
-  });
-
-  test('computeAllPatchSets', () => {
-    const expected = [
-      {num: 4, desc: 'test', sha: 'rev4'},
-      {num: 3, desc: 'test', sha: 'rev3'},
-      {num: 2, desc: 'test', sha: 'rev2'},
-      {num: 1, desc: 'test', sha: 'rev1'},
-    ];
-    const patchNums = computeAllPatchSets({
-      revisions: {
-        rev3: {_number: 3, description: 'test', date: 3},
-        rev1: {_number: 1, description: 'test', date: 1},
-        rev4: {_number: 4, description: 'test', date: 4},
-        rev2: {_number: 2, description: 'test', date: 2},
-      },
-    });
-    assert.equal(patchNums.length, expected.length);
-    for (let i = 0; i < expected.length; i++) {
-      assert.deepEqual(patchNums[i], expected[i]);
-    }
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.ts b/polygerrit-ui/app/utils/patch-set-util_test.ts
new file mode 100644
index 0000000..a9d9549
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util_test.ts
@@ -0,0 +1,246 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma';
+import {
+  createChange,
+  createChangeMessageInfo,
+  createRevision,
+} from '../test/test-data-generators';
+import {
+  BasePatchSetNum,
+  ChangeInfo,
+  EditPatchSetNum,
+  PatchSetNum,
+  ReviewInputTag,
+} from '../types/common';
+import {
+  _testOnly_computeWipForPatchSets,
+  computeAllPatchSets,
+  findEditParentPatchNum,
+  findEditParentRevision,
+  getParentIndex,
+  getRevisionByPatchNum,
+  isMergeParent,
+  sortRevisions,
+} from './patch-set-util';
+
+suite('gr-patch-set-util tests', () => {
+  test('getRevisionByPatchNum', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.deepEqual(
+      getRevisionByPatchNum(revisions, 1 as PatchSetNum),
+      revisions[1]
+    );
+    assert.deepEqual(
+      getRevisionByPatchNum(revisions, 2 as PatchSetNum),
+      revisions[2]
+    );
+    assert.equal(getRevisionByPatchNum(revisions, 3 as PatchSetNum), undefined);
+  });
+
+  test('_computeWipForPatchSets', () => {
+    // Compute patch sets for a given timeline on a change. The initial WIP
+    // property of the change can be true or false. The map of tags by
+    // revision is keyed by patch set number. Each value is a list of change
+    // message tags in the order that they occurred in the timeline. These
+    // indicate actions that modify the WIP property of the change and/or
+    // create new patch sets.
+    //
+    // Returns the actual results with an assertWip method that can be used
+    // to compare against an expected value for a particular patch set.
+    const compute = (
+      initialWip: boolean,
+      tagsByRevision: Map<
+        number | 'edit' | 'PARENT',
+        (ReviewInputTag | undefined)[]
+      >
+    ) => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [],
+        work_in_progress: initialWip,
+      };
+      for (const rev of tagsByRevision.keys()) {
+        for (const tag of tagsByRevision.get(rev)!) {
+          change.messages!.push({
+            ...createChangeMessageInfo(),
+            tag,
+            _revision_number: rev as PatchSetNum,
+          });
+        }
+      }
+      const patchSets = Array.from(tagsByRevision.keys()).map(rev => {
+        return {num: rev as PatchSetNum, desc: 'test', sha: `rev${rev}`};
+      });
+      const patchNums = _testOnly_computeWipForPatchSets(change, patchSets);
+      const verifier = {
+        assertWip(revision: number, expectedWip: boolean) {
+          const patchNum = patchNums.find(
+            patchNum => patchNum.num === (revision as PatchSetNum)
+          );
+          if (!patchNum) {
+            assert.fail(`revision ${revision} not found`);
+          }
+          assert.equal(
+            patchNum.wip,
+            expectedWip,
+            `wip state for ${revision} ` +
+              `is ${patchNum.wip}; expected ${expectedWip}`
+          );
+          return verifier;
+        },
+      };
+      return verifier;
+    };
+
+    const upload = 'upload' as ReviewInputTag;
+
+    compute(false, new Map([[1, [upload]]])).assertWip(1, false);
+    compute(true, new Map([[1, [upload]]])).assertWip(1, true);
+
+    const setWip = 'autogenerated:gerrit:setWorkInProgress' as ReviewInputTag;
+    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet' as ReviewInputTag;
+    const clearWip = 'autogenerated:gerrit:setReadyForReview' as ReviewInputTag;
+
+    compute(
+      false,
+      new Map([
+        [1, [upload, setWip]],
+        [2, [upload]],
+        [3, [upload, clearWip]],
+        [4, [upload, setWip]],
+      ])
+    )
+      .assertWip(1, false) // Change was created with PS1 ready for review
+      .assertWip(2, true) // PS2 was uploaded during WIP
+      .assertWip(3, false) // PS3 was marked ready for review after upload
+      .assertWip(4, false); // PS4 was uploaded ready for review
+
+    compute(
+      false,
+      new Map([
+        [1, [uploadInWip, undefined, 'addReviewer' as ReviewInputTag]],
+        [2, [upload]],
+        [3, [upload, clearWip, setWip]],
+        [4, [upload]],
+        [5, [upload, clearWip]],
+        [6, [uploadInWip]],
+      ])
+    )
+      .assertWip(1, true) // Change was created in WIP
+      .assertWip(2, true) // PS2 was uploaded during WIP
+      .assertWip(3, false) // PS3 was marked ready for review
+      .assertWip(4, true) // PS4 was uploaded during WIP
+      .assertWip(5, false) // PS5 was marked ready for review
+      .assertWip(6, true); // PS6 was uploaded with WIP option
+  });
+
+  test('isMergeParent', () => {
+    assert.isFalse(isMergeParent(1 as PatchSetNum));
+    assert.isFalse(isMergeParent(4321 as PatchSetNum));
+    assert.isFalse(isMergeParent('edit' as PatchSetNum));
+    assert.isFalse(isMergeParent('PARENT' as PatchSetNum));
+    assert.isFalse(isMergeParent(0 as PatchSetNum));
+
+    assert.isTrue(isMergeParent(-23 as PatchSetNum));
+    assert.isTrue(isMergeParent(-1 as PatchSetNum));
+  });
+
+  test('findEditParentRevision', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions.push({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 3 as BasePatchSetNum,
+    });
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions.push(createRevision(3));
+    assert.deepEqual(findEditParentRevision(revisions), createRevision(3));
+  });
+
+  test('findEditParentPatchNum', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.equal(findEditParentPatchNum(revisions), -1);
+
+    revisions.push(
+      {
+        ...createRevision(),
+        _number: EditPatchSetNum,
+        basePatchNum: 3 as BasePatchSetNum,
+      },
+      createRevision(3)
+    );
+    assert.deepEqual(findEditParentPatchNum(revisions), 3);
+  });
+
+  test('sortRevisions', () => {
+    const revisions = [createRevision(0), createRevision(2), createRevision(1)];
+    const sorted = [createRevision(2), createRevision(1), createRevision(0)];
+
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    // Edit patchset should follow directly after its basePatchNum.
+    revisions.push({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 2 as BasePatchSetNum,
+    });
+    sorted.unshift({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 2 as BasePatchSetNum,
+    });
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    revisions[0].basePatchNum = 0 as BasePatchSetNum;
+    const edit = sorted.shift()!;
+    edit.basePatchNum = 0 as BasePatchSetNum;
+    // Edit patchset should be at index 2.
+    sorted.splice(2, 0, edit);
+    assert.deepEqual(sortRevisions(revisions), sorted);
+  });
+
+  test('getParentIndex', () => {
+    assert.equal(getParentIndex(-4 as PatchSetNum), 4);
+  });
+
+  test('computeAllPatchSets', () => {
+    const expected = [
+      {num: 4 as PatchSetNum, desc: 'test', sha: 'rev4'},
+      {num: 3 as PatchSetNum, desc: 'test', sha: 'rev3'},
+      {num: 2 as PatchSetNum, desc: 'test', sha: 'rev2'},
+      {num: 1 as PatchSetNum, desc: 'test', sha: 'rev1'},
+    ];
+    const patchNums = computeAllPatchSets({
+      ...createChange(),
+      revisions: {
+        rev1: {...createRevision(1), description: 'test'},
+        rev2: {...createRevision(2), description: 'test'},
+        rev3: {...createRevision(3), description: 'test'},
+        rev4: {...createRevision(4), description: 'test'},
+      },
+    });
+    assert.equal(patchNums.length, expected.length);
+    for (let i = 0; i < expected.length; i++) {
+      assert.deepEqual(patchNums[i], expected[i]);
+    }
+  });
+});
diff --git a/proto/cache.proto b/proto/cache.proto
index b1722b4..9d2ba06 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -445,7 +445,7 @@
 }
 
 // Serialized form of com.google.gerrit.common.data.LabelType.
-// Next ID: 19
+// Next ID: 21
 message LabelTypeProto {
   string name = 1;
   string function = 2; // ENUM as String
@@ -466,6 +466,7 @@
   bool can_override = 17;
   repeated string ref_patterns = 18;
   bool copy_all_scores_if_list_of_files_did_not_change = 19;
+  string copy_condition = 20;
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirement.
@@ -474,7 +475,7 @@
   string name = 1;
   string description = 2;
   string applicability_expression = 3;
-  string blocking_expression = 4;
+  string submittability_expression = 4;
   string override_expression = 5;
   bool allow_override_in_child_projects = 6;
 }
@@ -685,3 +686,18 @@
   bytes new_commit = 10;
   ComparisonType comparison_type = 11;
 }
+
+// Serialized form of com.google.gerrit.server.approval.ApprovalCacheImpl.Key.
+// Next ID: 5
+message PatchSetApprovalsKeyProto {
+  string project = 1;
+  int32 change_id = 2;
+  int32 patch_set_id = 3;
+  bytes id = 4;
+}
+
+// Repeated version of PatchSetApprovalProto
+// Next ID: 2
+message AllPatchSetApprovalsProto {
+  repeated devtools.gerritcodereview.PatchSetApproval approval = 1;
+}
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 3a40d22..e1d6f22 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -16,6 +16,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+set -u
+
 # avoid [[ which is not POSIX sh.
 if test "$#" != 1 ; then
   echo "$0 requires an argument."
@@ -28,12 +30,17 @@
 fi
 
 # Do not create a change id if requested
-if test "false" = "`git config --bool --get gerrit.createChangeId`" ; then
+if test "false" = "$(git config --bool --get gerrit.createChangeId)" ; then
   exit 0
 fi
 
-# $RANDOM will be undefined if not using bash, so don't use set -u
-random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
+if git rev-parse --verify HEAD >/dev/null 2>&1; then
+  refhash="$(git rev-parse HEAD)"
+else
+  refhash="$(git hash-object -t tree /dev/null)"
+fi
+
+random=$({ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin)
 dest="$1.tmp.${random}"
 
 trap 'rm -f "${dest}"' EXIT
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 3d04592..434196f 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -6,7 +6,7 @@
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
-TESTCONTAINERS_VERSION = "1.15.1"
+TESTCONTAINERS_VERSION = "1.15.3"
 
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
@@ -187,21 +187,21 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    DOCKER_JAVA_VERS = "3.2.7"
+    DOCKER_JAVA_VERS = "3.2.8"
 
     maven_jar(
         name = "docker-java-api",
         artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS,
-        sha1 = "81408fc988c229ea11354fee9902c47842343f04",
+        sha1 = "4ac22a72d546a9f3523cd4b5fabffa77c4a6ec7c",
     )
 
     maven_jar(
         name = "docker-java-transport",
         artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS,
-        sha1 = "315903a129f530422747efc163dd255f0fa2555e",
+        sha1 = "c3b5598c67d0a5e2e780bf48f520da26b9915eab",
     )
 
-    # https://github.com/docker-java/docker-java/blob/3.2.7/pom.xml#L61
+    # https://github.com/docker-java/docker-java/blob/3.2.8/pom.xml#L61
     # <=> DOCKER_JAVA_VERS
     maven_jar(
         name = "jackson-annotations",
@@ -212,7 +212,7 @@
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "91e6dfab8f141f77c6a0dd147a94bd186993a22c",
+        sha1 = "95c6cfde71c2209f0c29cb14e432471e0b111880",
     )
 
     maven_jar(