Merge "Move ApprovalsUtil and ApprovalInference into approval package"

* submodules:
* Update plugins/reviewnotes from branch 'master'
  to 8074b2c179d31829a88c15406df51d53656f8541
  - Adapt to changes in gerrit core
    
    Depends-On: https://gerrit-review.googlesource.com/c/gerrit/+/308250
    Change-Id: I5702aedd365d34289ffbbea7d9aea580abec133e
    
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/e2e-tests/src/test/resources/hooks/commit-msg b/e2e-tests/src/test/resources/hooks/commit-msg
index b05a671..22a4b22 100644
--- a/e2e-tests/src/test/resources/hooks/commit-msg
+++ b/e2e-tests/src/test/resources/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."
@@ -32,8 +34,13 @@
   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)
+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}"
 
 # Avoid the --in-place option which only appeared in Git 2.8
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/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/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/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index ce26e5d..0f1d129 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -1323,7 +1323,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/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index c4ca460..ccd4109 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,34 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
-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 com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.StreamSupport;
 
 /** Predicate that is mapped to a field in the change index. */
-public class ChangeIndexPredicate extends IndexPredicate<ChangeData>
-    implements Matchable<ChangeData> {
-  /**
-   * 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"));
-
+public class ChangeIndexPredicate extends IndexPredicate<ChangeData> {
   /**
    * Returns an index predicate that matches no changes in the index.
    *
@@ -61,70 +39,4 @@
   protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
-
-  /**
-   * 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(ChangeData)} 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(ChangeData)}
-   *     tokenizes both the query and the indexed text and matches tokens individually.
-   *
-   * @return true if the predicate matches the provided {@link ChangeData}.
-   */
-  @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));
-    } 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).size() == tokenizedValue.size();
-    } 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/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/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/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 321e37e..fd593ee 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 321e37e448336c2ae7495808da5fa483e755141b
+Subproject commit fd593ee1d3cb19d73c49f94c0f9b4a78856d6198
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 5ff7832..8074b2c 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 5ff78329409f0ee8ad9502ab6bbd7882e6be1b74
+Subproject commit 8074b2c179d31829a88c15406df51d53656f8541
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/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index ef8f250..bd9103e 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,
@@ -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() {
@@ -455,13 +456,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}"
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 4b7aec5..53d25ec 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,12 +40,12 @@
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
-  allActions$,
-  allLinks$,
+  topLevelActionsSelected$,
+  topLevelLinksSelected$,
   CheckRun,
-  checksPatchsetNumber$,
   RunResult,
-  someProvidersAreLoading$,
+  checksSelectedPatchsetNumber$,
+  someProvidersAreLoadingSelected$,
 } from '../../services/checks/checks-model';
 import {
   allResults,
@@ -52,10 +53,11 @@
   hasCompletedWithoutResults,
   iconForCategory,
   iconForLink,
-  otherLinks,
-  primaryLink,
+  otherPrimaryLinks,
+  firstPrimaryLink,
   primaryRunAction,
   tooltipForLink,
+  secondaryLinks,
 } from '../../services/checks/checks-util';
 import {assertIsDefined, check} from '../../utils/common-util';
 import {toggleClass, whenVisible} from '../../utils/dom-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);
         }
@@ -820,8 +936,22 @@
   }
 
   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 +961,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 +987,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 +1098,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 21d0459..093eecb 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -40,9 +40,11 @@
   worstCategory,
 } from '../../services/checks/checks-util';
 import {
+  allRunsSelectedPatchset$,
   CheckRun,
-  allRuns$,
+  ChecksPatchset,
   fakeActions,
+  fakeLinks,
   fakeRun0,
   fakeRun1,
   fakeRun2,
@@ -52,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';
@@ -334,7 +335,7 @@
 
   constructor() {
     super();
-    this.subscribe('runs', allRuns$);
+    this.subscribe('runs', allRunsSelectedPatchset$);
   }
 
   static get styles() {
@@ -443,8 +444,8 @@
         ?hidden="${!this.showFilter()}"
         @input="${this.onInput}"
       />
-      ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNING)}
+      ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()}
     `;
   }
@@ -514,24 +515,31 @@
   }
 
   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(
@@ -541,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 a04c190..963d28a 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -21,9 +21,9 @@
 import {
   CheckResult,
   CheckRun,
-  allResults$,
-  allRuns$,
-  checksPatchsetNumber$,
+  allResultsSelected$,
+  checksSelectedPatchsetNumber$,
+  allRunsSelectedPatchset$,
 } from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
@@ -72,9 +72,9 @@
 
   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('changeNum', changeNum$);
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
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 424df2f..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
@@ -315,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) {
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 010786f..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
@@ -55,8 +55,21 @@
   private preventAutoScrollOnManualScroll = false;
 
   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);
+    }
   }
 
   get side(): Side {
@@ -478,19 +491,6 @@
     toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
   }
 
-  _updateSide(_: Side, oldSide: Side) {
-    if (!this.diffRow) {
-      return;
-    }
-    if (oldSide) {
-      this.fireCursorMoved('line-cursor-moved-out', this.diffRow, oldSide);
-    }
-    this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
-    }
-  }
-
   _isActionType(type: GrDiffRowType) {
     return (
       type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
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 2ce2abe..d626fe5 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
@@ -1233,6 +1233,9 @@
       }
       return;
     }
+    // shift + m navigates to next unreviewed file so request list of reviewed
+    // files even if manual review is not set
+    this._getReviewedFiles(this._changeNum, patchRange.patchNum);
 
     if (paramsRecord.base?.view === GerritNav.View.DIFF) {
       this._setReviewed(true);
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/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-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/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..7d5f053 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 f08732f..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
@@ -312,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-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/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/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