Merge "Revert "Revert "Get rid of ba-linkify."""
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index ba20cea..5ff9425 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -246,6 +246,15 @@
 name contains the file pattern, or the edits of the file diff contain the edit
 pattern.
 
+[[operator_label]]
+label:labelName=+1,user=non_contributor::
++
+Submit requirements support an additional `user=non_contributor` argument for
+labels that returns true if the change has a label vote matching the specified
+value and the vote is applied from a gerrit account that's not the uploader,
+author or committer of the latest patchset. See the documentation for the labels
+operator in the link:user-search.html[user search] page.
+
 [[unsupported_operators]]
 === Unsupported Operators
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index b761687..75d8847 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6878,9 +6878,6 @@
 accounts that were in the attention set but were removed. The
 link:#attention-set-info[AttentionSetInfo] is the latest and most recent removal
  of the account from the attention set.
-|`assignee`           |optional|
-The assignee of the change as an link:rest-api-accounts.html#account-info[
-AccountInfo] entity.
 |`hashtags`           |optional|
 List of hashtags that are set on the change.
 |`change_id`          ||The Change-Id of the change.
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index da033c1..8a9e56a 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -343,7 +343,6 @@
     public final TestAccount reviewer;
     public final TestAccount ccer;
     public final TestAccount starrer;
-    public final TestAccount assignee;
     public final TestAccount watchingProjectOwner;
     private final Map<NotifyType, TestAccount> watchers = new HashMap<>();
     private final Map<String, TestAccount> accountsByEmail = new HashMap<>();
@@ -369,7 +368,6 @@
           reviewer = reindexAndCopy(existing.reviewer);
           ccer = reindexAndCopy(existing.ccer);
           starrer = reindexAndCopy(existing.starrer);
-          assignee = reindexAndCopy(existing.assignee);
           watchingProjectOwner = reindexAndCopy(existing.watchingProjectOwner);
           watchers.putAll(existing.watchers);
           return;
@@ -381,7 +379,6 @@
         uploader = testAccount("uploader");
         ccer = testAccount("ccer");
         starrer = testAccount("starrer");
-        assignee = testAccount("assignee");
 
         watchingProjectOwner = testAccount("watchingProjectOwner", "Administrators");
         requestScopeOperations.setApiUser(watchingProjectOwner.id());
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 3c7ec2b..4238563 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -53,6 +53,7 @@
     "//lib/bouncycastle:bcpg",
     "//lib/bouncycastle:bcpkix",
     "//lib/bouncycastle:bcprov",
+    "//lib/bouncycastle:bcutil",
     "//prolog:gerrit-prolog-common",
 ]
 
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 55220f3..56fb748 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -477,9 +477,6 @@
    */
   @Nullable private String submissionId;
 
-  /** Allows assigning a change to a user. */
-  @Nullable private Account.Id assignee;
-
   /** Whether the change is private. */
   private boolean isPrivate;
 
@@ -509,7 +506,6 @@
   }
 
   public Change(Change other) {
-    assignee = other.assignee;
     changeId = other.changeId;
     changeKey = other.changeKey;
     createdOn = other.createdOn;
@@ -548,14 +544,6 @@
     changeKey = k;
   }
 
-  public Account.Id getAssignee() {
-    return assignee;
-  }
-
-  public void setAssignee(Account.Id a) {
-    assignee = a;
-  }
-
   public Instant getCreatedOn() {
     return createdOn;
   }
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 4903364..3b772d0 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -71,10 +71,6 @@
     if (submissionId != null) {
       builder.setSubmissionId(submissionId);
     }
-    Account.Id assignee = change.getAssignee();
-    if (assignee != null) {
-      builder.setAssignee(accountIdConverter.toProto(assignee));
-    }
     Change.Id revertOf = change.getRevertOf();
     if (revertOf != null) {
       builder.setRevertOf(changeIdConverter.toProto(revertOf));
@@ -114,9 +110,6 @@
     if (proto.hasSubmissionId()) {
       change.setSubmissionId(proto.getSubmissionId());
     }
-    if (proto.hasAssignee()) {
-      change.setAssignee(accountIdConverter.fromProto(proto.getAssignee()));
-    }
     change.setPrivate(proto.getIsPrivate());
     change.setWorkInProgress(proto.getWorkInProgress());
     change.setReviewStarted(proto.getReviewStarted());
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 1ee2cd8..020351b 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -134,7 +134,6 @@
   public DateFormat dateFormat;
   public TimeFormat timeFormat;
   public Boolean expandInlineDiffs;
-  public Boolean highlightAssigneeInChangeTable;
   public Boolean relativeDateInChangeTable;
   public DiffView diffView;
   public Boolean sizeBarInChangeTable;
@@ -195,7 +194,6 @@
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
     p.expandInlineDiffs = false;
-    p.highlightAssigneeInChangeTable = true;
     p.relativeDateInChangeTable = false;
     p.diffView = DiffView.SIDE_BY_SIDE;
     p.sizeBarInChangeTable = true;
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index 0142e01..f85279c 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -17,10 +17,8 @@
 /** API response containing values from the {@code change} section of {@code gerrit.config}. */
 public class ChangeConfigInfo {
   public Boolean allowBlame;
-  public Boolean showAssigneeInChangesTable;
   public Boolean disablePrivateChanges;
   public int updateDelay;
   public Boolean submitWholeTopic;
   public String mergeabilityComputationBehavior;
-  public Boolean enableAssignee;
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index a865187..b40e100 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -49,7 +49,6 @@
 
   public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
 
-  public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index a9d5959..e5a9534 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -123,7 +123,6 @@
         changeInfo.removedFromAttentionSet == null
             ? null
             : ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
-    copy.assignee = changeInfo.assignee;
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
     copy.submitType = changeInfo.submitType;
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 912d202..f733a7b 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -617,7 +617,6 @@
                       a -> a.account().get(),
                       a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
     }
-    out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
     if (in.isNew()) {
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 919586e..c5c0be0 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -177,9 +177,6 @@
     byte[] buf = new byte[20];
     Set<Account.Id> accounts = new HashSet<>();
     accounts.add(getChange().getOwner());
-    if (getChange().getAssignee() != null) {
-      accounts.add(getChange().getAssignee());
-    }
     try {
       patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
 
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 903a4c0..ad0dd8b 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -34,12 +34,14 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.edit.tree.DeleteFileModification;
 import com.google.gerrit.server.edit.tree.RenameFileModification;
 import com.google.gerrit.server.edit.tree.RestoreFileModification;
 import com.google.gerrit.server.edit.tree.TreeCreator;
 import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -108,15 +110,15 @@
       PermissionBackend permissionBackend,
       ChangeEditUtil changeEditUtil,
       PatchSetUtil patchSetUtil,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      GitReferenceUpdated gitReferenceUpdated) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
     this.zoneId = gerritIdent.getZoneId();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
-
-    noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser);
+    noteDbEdits = new NoteDbEdits(gitReferenceUpdated, zoneId, indexer, currentUser);
   }
 
   /**
@@ -173,10 +175,14 @@
               notes.getChangeId(), currentPatchSet.id()));
     }
 
-    rebase(repository, changeEdit, currentPatchSet);
+    rebase(notes.getProjectName(), repository, changeEdit, currentPatchSet);
   }
 
-  private void rebase(Repository repository, ChangeEdit changeEdit, PatchSet currentPatchSet)
+  private void rebase(
+      Project.NameKey project,
+      Repository repository,
+      ChangeEdit changeEdit,
+      PatchSet currentPatchSet)
       throws IOException, MergeConflictException, InvalidChangeOperationException {
     RevCommit currentEditCommit = changeEdit.getEditCommit();
     if (currentEditCommit.getParentCount() == 0) {
@@ -194,7 +200,13 @@
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     noteDbEdits.baseEditOnDifferentPatchset(
-        repository, changeEdit, currentPatchSet, currentEditCommit, newEditCommitId, nowTimestamp);
+        project,
+        repository,
+        changeEdit,
+        currentPatchSet,
+        currentEditCommit,
+        newEditCommitId,
+        nowTimestamp);
   }
 
   /**
@@ -719,11 +731,17 @@
     private final ZoneId zoneId;
     private final ChangeIndexer indexer;
     private final Provider<CurrentUser> currentUser;
+    private final GitReferenceUpdated gitReferenceUpdated;
 
-    NoteDbEdits(ZoneId zoneId, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
+    NoteDbEdits(
+        GitReferenceUpdated gitReferenceUpdated,
+        ZoneId zoneId,
+        ChangeIndexer indexer,
+        Provider<CurrentUser> currentUser) {
       this.zoneId = zoneId;
       this.indexer = indexer;
       this.currentUser = currentUser;
+      this.gitReferenceUpdated = gitReferenceUpdated;
     }
 
     ChangeEdit createEdit(
@@ -753,6 +771,10 @@
       return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchset.id());
     }
 
+    private AccountState getUpdater() {
+      return currentUser.get().asIdentifiedUser().state();
+    }
+
     ChangeEdit updateEdit(
         Project.NameKey projectName,
         Repository repository,
@@ -795,9 +817,11 @@
           throw new IOException(message);
         }
       }
+      gitReferenceUpdated.fire(projectName, ru, getUpdater());
     }
 
     void baseEditOnDifferentPatchset(
+        Project.NameKey project,
         Repository repository,
         ChangeEdit changeEdit,
         PatchSet currentPatchSet,
@@ -807,6 +831,7 @@
         throws IOException {
       String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
       updateReferenceWithNameChange(
+          project,
           repository,
           changeEdit.getRefName(),
           currentEditCommit,
@@ -817,6 +842,7 @@
     }
 
     private void updateReferenceWithNameChange(
+        Project.NameKey projectName,
         Repository repository,
         String currentRefName,
         ObjectId currentObjectId,
@@ -838,6 +864,7 @@
           throw new IOException("failed: " + cmd);
         }
       }
+      gitReferenceUpdated.fire(projectName, batchRefUpdate, getUpdater());
     }
 
     static RevCommit lookupCommit(Repository repository, ObjectId commitId) throws IOException {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 74834ab..3474590 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -70,6 +71,8 @@
   private final ChangeKindCache changeKindCache;
   private final PatchSetUtil psUtil;
 
+  private final GitReferenceUpdated gitReferenceUpdated;
+
   @Inject
   ChangeEditUtil(
       GitRepositoryManager gitManager,
@@ -77,13 +80,15 @@
       ChangeIndexer indexer,
       Provider<CurrentUser> userProvider,
       ChangeKindCache changeKindCache,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      GitReferenceUpdated gitReferenceUpdated) {
     this.gitManager = gitManager;
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.indexer = indexer;
     this.userProvider = userProvider;
     this.changeKindCache = changeKindCache;
     this.psUtil = psUtil;
+    this.gitReferenceUpdated = gitReferenceUpdated;
   }
 
   /**
@@ -237,7 +242,7 @@
     return writeSquashedCommit(rw, inserter, parent, edit);
   }
 
-  private static void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
+  private void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
     String refName = edit.getRefName();
     RefUpdate ru = repo.updateRef(refName, true);
     ru.setExpectedOldObjectId(edit.getEditCommit());
@@ -261,6 +266,10 @@
       default:
         throw new IOException(String.format("Failed to delete ref %s: %s", refName, result));
     }
+    gitReferenceUpdated.fire(
+        edit.getChange().getProject(),
+        ru,
+        /* updater= */ userProvider.get().asIdentifiedUser().state());
   }
 
   private static RevCommit writeSquashedCommit(
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 2045dba..8e443f82 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -504,9 +504,9 @@
           ATTENTION_SET_FULL_FIELD.storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL);
 
   /** The user assigned to the change. */
+  // The getter always returns NO_ASSIGNEE, since assignee field is deprecated.
   public static final IndexedField<ChangeData, Integer> ASSIGNEE_FIELD =
-      IndexedField.<ChangeData>integerBuilder("Assignee")
-          .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
+      IndexedField.<ChangeData>integerBuilder("Assignee").build(changeGetter(c -> NO_ASSIGNEE));
 
   public static final IndexedField<ChangeData, Integer>.SearchSpec ASSIGNEE_SPEC =
       ASSIGNEE_FIELD.integer(ChangeQueryBuilder.FIELD_ASSIGNEE);
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 895c4d8..6ddf7a3 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -232,12 +232,21 @@
           .build();
 
   /** Add prefixsubject field. */
+  @Deprecated
   static final Schema<ChangeData> V81 =
       new Schema.Builder<ChangeData>()
           .add(V80)
           .addSearchSpecs(ChangeField.PREFIX_SUBJECT_SPEC)
           .build();
 
+  /** Remove assignee field. */
+  static final Schema<ChangeData> V82 =
+      new Schema.Builder<ChangeData>()
+          .add(V81)
+          .remove(ChangeField.ASSIGNEE_SPEC)
+          .remove(ChangeField.ASSIGNEE_FIELD)
+          .build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index ce54708..0eaafb8 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -90,7 +90,7 @@
     "RevertedHtml.soy",
   };
 
-  private static final SoySauce DEFAULT = getDefault().build().compileTemplates();
+  private static final SoySauce DEFAULT = getDefault(null).build().compileTemplates();
 
   private final SitePaths site;
   private final PluginSetContext<MailSoyTemplateProvider> templateProviders;
@@ -106,7 +106,7 @@
       return DEFAULT;
     }
 
-    SoyFileSet.Builder builder = getDefault();
+    SoyFileSet.Builder builder = getDefault(site);
     templateProviders.runEach(
         e -> e.getFileNames().forEach(p -> addTemplate(builder, site, e.getPath(), p)));
     return builder.build().compileTemplates();
@@ -124,10 +124,10 @@
     }
   }
 
-  private static SoyFileSet.Builder getDefault() {
+  private static SoyFileSet.Builder getDefault(@Nullable SitePaths site) {
     SoyFileSet.Builder builder = SoyFileSet.builder();
     for (String name : TEMPLATES) {
-      addTemplate(builder, null, "com/google/gerrit/server/mail/", name);
+      addTemplate(builder, site, "com/google/gerrit/server/mail/", name);
     }
     return builder;
   }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index f60e9e5..870fc3e 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -155,6 +155,8 @@
       PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
       return SubmitRequirementExpressionResult.create(expression, predicateResult);
     } catch (QueryParseException | SubmitRequirementEvaluationException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed to evaluate submit requirement expression: %s", expression.expressionString());
       return SubmitRequirementExpressionResult.error(expression, e.getMessage());
     }
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 738eab3..f6fc8db 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -225,9 +225,11 @@
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
   public static final String ARG_ID_NON_UPLOADER = "non_uploader";
+  public static final String ARG_ID_NON_CONTRIBUTOR = "non_contributor";
   public static final String ARG_COUNT = "count";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
+  public static final Account.Id NON_CONTRIBUTOR_ACCOUNT_ID = Account.id(-2);
 
   public static final String OPERATOR_MERGED_BEFORE = "mergedbefore";
   public static final String OPERATOR_MERGED_AFTER = "mergedafter";
@@ -485,13 +487,13 @@
     }
   }
 
-  private final Arguments args;
+  protected final Arguments args;
   protected Map<String, String> hasOperandAliases = Collections.emptyMap();
   private Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
 
   private static final Splitter RULE_SPLITTER = Splitter.on("=");
   private static final Splitter PLUGIN_SPLITTER = Splitter.on("_");
-  private static final Splitter LABEL_SPLITTER = Splitter.on(",");
+  protected static final Splitter LABEL_SPLITTER = Splitter.on(",");
 
   @Inject
   protected ChangeQueryBuilder(Arguments args) {
@@ -1038,6 +1040,8 @@
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
           } else if (value.equals(ARG_ID_NON_UPLOADER)) {
             accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
+          } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
+            accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
           } else {
             accounts = parseAccount(value);
           }
@@ -1072,6 +1076,8 @@
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
           } else if (value.equals(ARG_ID_NON_UPLOADER)) {
             accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
+          } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
+            accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
           } else {
             accounts = parseAccount(value);
           }
@@ -1106,9 +1112,16 @@
       }
     }
 
+    validateLabelArgs(accounts);
     return new LabelPredicate(args, name, accounts, group, count, countOp);
   }
 
+  protected void validateLabelArgs(Set<Account.Id> accounts) throws QueryParseException {
+    if (accounts != null && accounts.contains(NON_CONTRIBUTOR_ACCOUNT_ID)) {
+      throw new QueryParseException("non_contributor arg is not allowed in change queries");
+    }
+  }
+
   /** Assert that keys {@code k1} and {@code k2} do not exist in {@code labelArgs} together. */
   private void assertDisjunctive(PredicateArgs labelArgs, String k1, String k2)
       throws QueryParseException {
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 5662e4d..83dd5ba 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -23,6 +24,8 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -30,9 +33,15 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
+import java.io.IOException;
+import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class EqualsLabelPredicate extends ChangeIndexPostFilterPredicate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  protected final AccountResolver accountResolver;
   protected final ProjectCache projectCache;
   protected final PermissionBackend permissionBackend;
   protected final IdentifiedUser.GenericFactory userFactory;
@@ -61,6 +70,7 @@
       @Nullable Integer count) {
     super(ChangeField.LABEL_SPEC, ChangeField.formatLabel(label, expVal, account, count));
     this.permissionBackend = args.permissionBackend;
+    this.accountResolver = args.accountResolver;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
     this.count = count;
@@ -155,6 +165,14 @@
           && cd.currentPatchSet().uploader().equals(approver)) {
         return false;
       }
+
+      if (account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID)) {
+        if ((cd.currentPatchSet().uploader().equals(approver)
+            || matchAccount(cd.getCommitter().getEmailAddress(), approver)
+            || matchAccount(cd.getAuthor().getEmailAddress(), approver))) {
+          return false;
+        }
+      }
     }
 
     IdentifiedUser reviewer = userFactory.create(approver);
@@ -176,9 +194,24 @@
     }
   }
 
+  /**
+   * Returns true if the {@code email} parameter belongs to the account identified by the {@code
+   * accountId} parameter.
+   */
+  private boolean matchAccount(String email, Account.Id accountId) {
+    try {
+      List<AccountState> accountsList = accountResolver.resolve(email).asList();
+      return accountsList.stream().anyMatch(c -> c.account().id().equals(accountId));
+    } catch (ConfigInvalidException | IOException e) {
+      logger.atWarning().withCause(e).log("Failed to resolve account %s", email);
+    }
+    return false;
+  }
+
   private boolean isMagicUser() {
     return account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
-        || account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID);
+        || account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
+        || account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2a5a47d..d89940d 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.index.query.RangeUtil;
 import com.google.gerrit.index.query.RangeUtil.Range;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
@@ -36,6 +37,7 @@
   protected static final int MAX_COUNT = 5; // inclusive
 
   protected static class Args {
+    protected final AccountResolver accountResolver;
     protected final ProjectCache projectCache;
     protected final PermissionBackend permissionBackend;
     protected final IdentifiedUser.GenericFactory userFactory;
@@ -46,6 +48,7 @@
     protected final PredicateArgs.Operator countOp;
 
     protected Args(
+        AccountResolver accountResolver,
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
         IdentifiedUser.GenericFactory userFactory,
@@ -54,6 +57,7 @@
         AccountGroup.UUID group,
         @Nullable Integer count,
         @Nullable PredicateArgs.Operator countOp) {
+      this.accountResolver = accountResolver;
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.userFactory = userFactory;
@@ -89,6 +93,7 @@
     super(
         predicates(
             new Args(
+                a.accountResolver,
                 a.projectCache,
                 a.permissionBackend,
                 a.userFactory,
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index 5632c14..cb92ddd 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.base.Splitter;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -30,6 +31,7 @@
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
@@ -179,6 +181,9 @@
     return fileEditsPredicateFactory.create(FileEditsArgs.create(filePattern, contentPattern));
   }
 
+  @Override
+  protected void validateLabelArgs(Set<Account.Id> accountIds) throws QueryParseException {}
+
   private static void validateRegularExpression(String pattern, String errorMessage)
       throws QueryParseException {
     try {
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 3d9d588..b262b46 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -259,10 +259,13 @@
 
   /**
    * Bots don't process automatic rules, the only attention set change they do is this rule: Add
-   * owner and uploader when a bot votes negatively.
+   * owner and uploader when a bot votes negatively, but only if the change is open.
    */
   private void botsWithNegativeLabelsAddOwnerAndUploader(
       BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
+    if (changeNotes.getChange().isClosed()) {
+      return;
+    }
     if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
       Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
       Account.Id owner = changeNotes.getChange().getOwner();
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 66536dd..903e5b1 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -55,8 +55,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.plugincontext.PluginMapContext;
@@ -93,7 +91,6 @@
   private final QueryDocumentationExecutor docSearcher;
   private final ProjectCache projectCache;
   private final AgreementJson agreementJson;
-  private final ChangeIndexCollection indexes;
   private final SitePaths sitePaths;
 
   @Inject
@@ -116,7 +113,6 @@
       QueryDocumentationExecutor docSearcher,
       ProjectCache projectCache,
       AgreementJson agreementJson,
-      ChangeIndexCollection indexes,
       SitePaths sitePaths) {
     this.config = config;
     this.accountVisibilityProvider = accountVisibilityProvider;
@@ -136,7 +132,6 @@
     this.docSearcher = docSearcher;
     this.projectCache = projectCache;
     this.agreementJson = agreementJson;
-    this.indexes = indexes;
     this.sitePaths = sitePaths;
   }
 
@@ -220,11 +215,6 @@
   private ChangeConfigInfo getChangeInfo() {
     ChangeConfigInfo info = new ChangeConfigInfo();
     info.allowBlame = toBoolean(config.getBoolean("change", "allowBlame", true));
-    boolean hasAssigneeInIndex =
-        indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE_SPEC);
-    info.showAssigneeInChangesTable =
-        toBoolean(
-            config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
     info.updateDelay =
         (int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
     info.submitWholeTopic = toBoolean(MergeSuperSet.wholeTopicEnabled(config));
@@ -232,7 +222,6 @@
         toBoolean(this.config.getBoolean("change", null, "disablePrivateChanges", false));
     info.mergeabilityComputationBehavior =
         MergeabilityComputationBehavior.fromConfig(config).name();
-    info.enableAssignee = false;
     return info;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index f5b311b..5745a4e 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -78,7 +78,6 @@
     i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
     i.disableKeyboardShortcuts = true;
     i.expandInlineDiffs ^= true;
-    i.highlightAssigneeInChangeTable ^= true;
     i.relativeDateInChangeTable ^= true;
     i.sizeBarInChangeTable ^= true;
     i.legacycidInChangeTable ^= true;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index 56e23a4..2fe7038 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -17,6 +17,8 @@
 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.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.codeReview;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.eclipse.jgit.lib.Constants.HEAD;
@@ -26,6 +28,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
@@ -34,15 +38,21 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+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.merge.MergeStrategy;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -294,6 +304,162 @@
         "unexpected base value format");
   }
 
+  @Test
+  public void nonContributorLabelVote_match() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    TestRepository<InMemoryRepository> clonedRepo = cloneProject(project, user);
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(user.newIdent(), clonedRepo, "Subject", "file.txt", "text")
+            .to("refs/for/master");
+
+    Change.Id cId = r1.getChange().getId();
+
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+
+    // Assert on uploader, committer and author
+    assertUploader(changeInfo, user.email());
+    assertCommitter(changeInfo, user.email());
+    assertAuthor(changeInfo, user.email());
+
+    // Vote from admin (a.k.a. non uploader/committer/author) matches
+    requestScopeOperations.setApiUser(admin.id());
+    approve(cId.toString());
+    assertMatching("label:Code-Review=+2,user=non_contributor", cId);
+    // Also make sure magic label votes and > operator work
+    assertMatching("label:Code-Review=MAX,user=non_contributor", cId);
+    assertMatching("label:Code-Review>+1,user=non_contributor", cId);
+  }
+
+  @Test
+  public void nonContributorLabelVote_voteFromUploader_doesNotMatch() throws Exception {
+    PushOneCommit.Result r1 = createNormalCommit(user.newIdent(), "refs/for/master", "file1");
+
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+    assertUploader(changeInfo, admin.email());
+
+    // Vote from admin (a.k.a. uploader) does not match
+    requestScopeOperations.setApiUser(admin.id());
+    approve(r1.getChangeId());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+  }
+
+  @Test
+  @Sandboxed
+  public void nonContributorLabelVote_voteFromAuthor_doesNotMatch() throws Exception {
+    Account.Id authorId =
+        accountOperations
+            .newAccount()
+            .fullname("author")
+            .preferredEmail("authoremail@example.com")
+            .create();
+    Account.Id committerId =
+        accountOperations
+            .newAccount()
+            .fullname("committer")
+            .preferredEmail("committeremail@example.com")
+            .create();
+
+    Change.Id changeId =
+        changeOperations.newChange().author(authorId).committer(committerId).create();
+    ChangeInfo changeInfo = gApi.changes().id(changeId.get()).get();
+    assertAuthor(changeInfo, "authoremail@example.com");
+
+    allowLabelPermission(
+        codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+    // Vote from author does not match
+    requestScopeOperations.setApiUser(authorId);
+    approve(changeId.toString());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", changeId);
+  }
+
+  @Test
+  public void nonContributorLabelVote_voteFromCommitter_doesNotMatch() throws Exception {
+    Account.Id authorId =
+        accountOperations
+            .newAccount()
+            .fullname("author")
+            .preferredEmail("authoremail@example.com")
+            .create();
+    Account.Id committerId =
+        accountOperations
+            .newAccount()
+            .fullname("committer")
+            .preferredEmail("committeremail@example.com")
+            .create();
+
+    Change.Id changeId =
+        changeOperations.newChange().author(authorId).committer(committerId).create();
+    ChangeInfo changeInfo = gApi.changes().id(changeId.get()).get();
+    assertCommitter(changeInfo, "committeremail@example.com");
+
+    allowLabelPermission(
+        codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+    // Vote from committer does not match
+    requestScopeOperations.setApiUser(committerId);
+    approve(changeId.toString());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", changeId);
+  }
+
+  @Test
+  public void nonContributorLabelVote_uploaderAndAuthorDifferent() throws Exception {
+    TestRepository<InMemoryRepository> clonedRepo = cloneProject(project, admin);
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(user.newIdent(), clonedRepo, "Subject", "file.txt", "text")
+            .to("refs/for/master");
+
+    requestScopeOperations.setApiUser(admin.id());
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+    assertUploader(changeInfo, admin.email());
+    assertAuthor(changeInfo, user.email());
+
+    allowLabelPermission(
+        codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+    // Vote from admin (a.k.a. uploader) does not match
+    requestScopeOperations.setApiUser(user.id());
+    approve(r1.getChangeId());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+
+    // Vote from user (a.k.a. author) does not match
+    requestScopeOperations.setApiUser(admin.id());
+    approve(r1.getChangeId());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+
+    // Vote from user2 (a.k.a. non-author and non-uploader) matches
+    TestAccount user2 = accountCreator.create();
+    requestScopeOperations.setApiUser(user2.id());
+    approve(r1.getChangeId());
+    assertMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+  }
+
+  private static void assertUploader(ChangeInfo changeInfo, String email) {
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).uploader.email)
+        .isEqualTo(email);
+  }
+
+  private static void assertCommitter(ChangeInfo changeInfo, String email) {
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.committer.email)
+        .isEqualTo(email);
+  }
+
+  private static void assertAuthor(ChangeInfo changeInfo, String email) {
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.author.email)
+        .isEqualTo(email);
+  }
+
+  private void allowLabelPermission(
+      String labelName, String refPattern, AccountGroup.UUID group, int minVote, int maxVote) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(labelName).ref(refPattern).group(group).range(minVote, maxVote))
+        .update();
+  }
+
   private PushOneCommit.Result createGitSubmoduleCommit(String ref) throws Exception {
     return pushFactory
         .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of())
@@ -302,6 +468,13 @@
         .to(ref);
   }
 
+  private PushOneCommit.Result createNormalCommit(
+      PersonIdent personIdent, String ref, String fileName) throws Exception {
+    return pushFactory
+        .create(personIdent, testRepo, "subject", ImmutableMap.of(fileName, fileName))
+        .to(ref);
+  }
+
   private PushOneCommit.Result createNormalCommit(String ref, String fileName) throws Exception {
     return pushFactory
         .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of(fileName, fileName))
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 443e064..1d2ddc2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -1488,6 +1488,64 @@
   }
 
   @Test
+  public void robotReviewWithNegativeLabelDoesntAddOwnerIfChangeIsMerged() throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+
+    PushOneCommit.Result r = createChange();
+
+    // The robot votes with Code-Review-1 on patch set 1.
+    // Without this vote the robot cannot (re-)apply a negative vote on the change after it was
+    // merged change later.
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).revision(1).review(ReviewInput.dislike());
+
+    // Amend the change so that patch set 2 gets created.
+    requestScopeOperations.setApiUser(admin.id());
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    // Approve the change.
+    approve(r.getChangeId());
+
+    // User adds a comment so that the admin user is added to the attention set.
+    // This has to be a comment from a user, since comments from robots do not trigger attention set
+    // updates.
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "A comment";
+    change(r).current().review(reviewInput);
+
+    // Verify that the admin user was added to the attention set.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+
+    // Submit the change.
+    requestScopeOperations.setApiUser(admin.id());
+    change(r).current().submit();
+
+    // Verify that the attention set was cleared on submit.
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+
+    // Re-apply the negative robot vote on patch set 1.
+    // Note it's possible to a apply a negative vote on merged changes if it wasn't already present
+    // since we disallow downgrading votes on merged changes (e.g. downgrade from not present aka 0
+    // to -1 is not allowed).
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).revision(1).review(ReviewInput.dislike());
+
+    // Verify that re-applying the negative robot vote on patch set 1 didn't add the admin user
+    // back to the attention set.
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+  }
+
+  @Test
   public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
     TestAccount robot =
         accountCreator.create(
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 45a471b..f728995 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -15,16 +15,25 @@
 package com.google.gerrit.acceptance.server.mail;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
+import com.google.gerrit.server.config.SitePaths;
 import java.net.URI;
+import java.nio.file.Files;
 import java.util.Map;
+import javax.inject.Inject;
 import org.junit.Test;
 
+@UseLocalDisk
 public class MailSenderIT extends AbstractMailIT {
 
+  @Inject private SitePaths sitePaths;
+
   @Test
   @GerritConfig(name = "sendemail.replyToAddress", value = "custom@gerritcodereview.com")
   @GerritConfig(name = "receiveemail.protocol", value = "POP3")
@@ -63,6 +72,20 @@
     assertThat(headerString(headers, "In-Reply-To")).isEqualTo(threadId);
   }
 
+  @Test
+  @Sandboxed
+  public void useCustomTemplates() throws Exception {
+    String customTemplate =
+        "{namespace com.google.gerrit.server.mail.template.ChangeSubject}\n"
+            + "\n"
+            + "{template ChangeSubject kind=\"text\"}CUSTOM-TEMPLATE{/template}\n";
+    Files.write(sitePaths.mail_dir.resolve("ChangeSubject.soy"), customTemplate.getBytes(UTF_8));
+
+    createChangeWithReview(user);
+    String subject = headerString(sender.getMessages().iterator().next().headers(), "Subject");
+    assertThat(subject).isEqualTo("CUSTOM-TEMPLATE");
+  }
+
   private String headerString(Map<String, EmailHeader> headers, String name) {
     EmailHeader header = headers.get(name);
     assertThat(header).isInstanceOf(StringEmailHeader.class);
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bd4b2b1..bbf10bd 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -48,7 +48,6 @@
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
     change.setTopic("my topic");
     change.setSubmissionId("submission ID 234");
-    change.setAssignee(Account.id(100001));
     change.setPrivate(true);
     change.setWorkInProgress(true);
     change.setReviewStarted(true);
@@ -73,7 +72,6 @@
             .setTopic("my topic")
             .setOriginalSubject("original subject ABC")
             .setSubmissionId("submission ID 234")
-            .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
             .setIsPrivate(true)
             .setWorkInProgress(true)
             .setReviewStarted(true)
@@ -205,7 +203,6 @@
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
     change.setTopic("my topic");
     change.setSubmissionId("submission ID 234");
-    change.setAssignee(Account.id(100001));
     change.setPrivate(true);
     change.setWorkInProgress(true);
     change.setReviewStarted(true);
@@ -289,7 +286,6 @@
                 .put("topic", String.class)
                 .put("originalSubject", String.class)
                 .put("submissionId", String.class)
-                .put("assignee", Account.Id.class)
                 .put("isPrivate", boolean.class)
                 .put("workInProgress", boolean.class)
                 .put("reviewStarted", boolean.class)
@@ -313,7 +309,6 @@
     assertThat(change.getTopic()).isEqualTo(expectedChange.getTopic());
     assertThat(change.getOriginalSubject()).isEqualTo(expectedChange.getOriginalSubject());
     assertThat(change.getSubmissionId()).isEqualTo(expectedChange.getSubmissionId());
-    assertThat(change.getAssignee()).isEqualTo(expectedChange.getAssignee());
     assertThat(change.isPrivate()).isEqualTo(expectedChange.isPrivate());
     assertThat(change.isWorkInProgress()).isEqualTo(expectedChange.isWorkInProgress());
     assertThat(change.hasReviewStarted()).isEqualTo(expectedChange.hasReviewStarted());
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 8a7d25a..3704969 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -94,61 +94,6 @@
   }
 
   @Test
-  public void getDiff_givenEqualAssignees_returnsNullAssignee() {
-    ChangeInfo oldChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
-    ChangeInfo newChangeInfo =
-        createChangeInfoWithAccount(
-            new AccountInfo(oldChangeInfo.assignee.name, oldChangeInfo.assignee.email));
-
-    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
-    assertThat(diff.added().assignee).isNull();
-    assertThat(diff.removed().assignee).isNull();
-  }
-
-  @Test
-  public void getDiff_givenNewAssignee_returnsAssignee() {
-    ChangeInfo oldChangeInfo = new ChangeInfo();
-    ChangeInfo newChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
-
-    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
-    assertThat(diff.added().assignee).isEqualTo(newChangeInfo.assignee);
-    assertThat(diff.removed().assignee).isNull();
-  }
-
-  @Test
-  public void getDiff_withRemovedAssignee_returnsAssignee() {
-    ChangeInfo oldChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
-    ChangeInfo newChangeInfo = new ChangeInfo();
-
-    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
-    assertThat(diff.added().assignee).isNull();
-    assertThat(diff.removed().assignee).isEqualTo(oldChangeInfo.assignee);
-  }
-
-  @Test
-  public void getDiff_givenAssigneeWithNewName_returnsNameButNotEmail() {
-    ChangeInfo oldChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("old name", "mail@mail.com"));
-    ChangeInfo newChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("new name", oldChangeInfo.assignee.email));
-
-    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
-    assertThat(diff.added().assignee).isNotNull();
-    assertThat(diff.added().assignee.name).isEqualTo(newChangeInfo.assignee.name);
-    assertThat(diff.added().assignee.email).isNull();
-    assertThat(diff.removed().assignee).isNotNull();
-    assertThat(diff.removed().assignee.name).isEqualTo(oldChangeInfo.assignee.name);
-    assertThat(diff.removed().assignee.email).isNull();
-  }
-
-  @Test
   public void getDiff_whenHashtagsChanged_returnsHashtags() {
     String removedHashtag = "removed";
     String addedHashtag = "added";
@@ -708,12 +653,6 @@
     return changeInfo;
   }
 
-  private static ChangeInfo createChangeInfoWithAccount(AccountInfo accountInfo) {
-    ChangeInfo changeInfo = new ChangeInfo();
-    changeInfo.assignee = accountInfo;
-    return changeInfo;
-  }
-
   private static ChangeInfo createChangeInfoWithHashtags(String... hashtags) {
     ChangeInfo changeInfo = new ChangeInfo();
     changeInfo.hashtags = ImmutableList.copyOf(hashtags);
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index cd7a97b..0533f3f 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1435,6 +1435,15 @@
     // "count" and "group" args cannot be used simultaneously.
     assertThrows(
         BadRequestException.class, () -> assertQuery("label:Code-Review=+1,group=gerrit,count=2"));
+
+    // "non_contributor arg for the label operator is not allowed in change queries
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> assertQuery("label:Code-Review=+2,user=non_contributor"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("non_contributor arg is not allowed in change queries");
   }
 
   @Test
@@ -3898,14 +3907,6 @@
   }
 
   @Test
-  public void byOwnerInvalidQuery() throws Exception {
-    repo = createAndOpenProject("repo");
-    insert("repo", newChange(repo), userId);
-    String nameEmail = user.asIdentifiedUser().getNameEmail();
-    assertQuery("owner: \"" + nameEmail + "\"\\");
-  }
-
-  @Test
   public void byDeletedChange() throws Exception {
     repo = createAndOpenProject("repo");
     Change change = insert("repo", newChange(repo));
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 5cae012..7f383f9 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -59,16 +59,9 @@
   }
 
   @Test
-  @Override
-  public void byOwnerInvalidQuery() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo), userId);
-    String nameEmail = user.asIdentifiedUser().getNameEmail();
-
+  public void invalidQuery() throws Exception {
     BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () -> assertQuery("owner: \"" + nameEmail + "\"\\", change1));
+        assertThrows(BadRequestException.class, () -> newQuery("\\").get());
     assertThat(thrown).hasMessageThat().contains("Cannot create full-text query with value: \\");
   }
 
diff --git a/lib/bouncycastle/BUILD b/lib/bouncycastle/BUILD
index 43ba6e1..6a87d73 100644
--- a/lib/bouncycastle/BUILD
+++ b/lib/bouncycastle/BUILD
@@ -22,6 +22,13 @@
 )
 
 java_library(
+    name = "bcutil",
+    data = ["//lib:LICENSE-bouncycastle"],
+    visibility = ["//visibility:public"],
+    exports = ["@bcutil//jar"],
+)
+
+java_library(
     name = "bcprov-neverlink",
     data = ["//lib:LICENSE-bouncycastle"],
     neverlink = 1,
@@ -44,3 +51,11 @@
     visibility = ["//visibility:public"],
     exports = ["@bcpkix//jar"],
 )
+
+java_library(
+    name = "bcutil-neverlink",
+    data = ["//lib:LICENSE-bouncycastle"],
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = ["@bcutil//jar"],
+)
diff --git a/package.json b/package.json
index ae1bb2f..362b9dc 100644
--- a/package.json
+++ b/package.json
@@ -35,8 +35,8 @@
   "scripts": {
     "setup": "yarn && yarn --cwd=polygerrit-ui && yarn --cwd=polygerrit-ui/app",
     "clean": "git clean -fdx && bazel clean --expunge",
-    "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
-    "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
+    "compile": "tsc --project ./polygerrit-ui/app/tsconfig.json",
+    "compile:watch": "npm run compile -- --preserveWatchOutput --watch",
     "start": "run-p -rl compile:watch start:server",
     "start:server": "web-dev-server",
     "test": "yarn --cwd=polygerrit-ui test",
@@ -50,7 +50,8 @@
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
-    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis"
+    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
+    "lint": "eslint -c polygerrit-ui/app/.eslintrc.js --ignore-path polygerrit-ui/app/.eslintignore polygerrit-ui/app"
   },
   "repository": {
     "type": "git",
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 510ce54..a89c0b7 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -189,7 +189,7 @@
 Compiling code:
 ```sh
 # Compile frontend once to check for type errors:
-yarn compile:local
+yarn compile
 
 # Watch mode:
 yarn compile:watch
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index f915432..b9ed56b 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -258,6 +258,8 @@
   NONE = 'NONE',
 }
 
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
 export function createDefaultPreferences(): PreferencesInfo {
   return {
     changes_per_page: 25,
@@ -265,8 +267,8 @@
     size_bar_in_change_table: true,
     my: [],
     theme: AppTheme.AUTO,
-    date_format: DateFormat.EURO,
-    time_format: TimeFormat.HHMM_24,
+    date_format: DateFormat.STD,
+    time_format: TimeFormat.HHMM_12,
     change_table: [],
     email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
     default_base_for_merges: DefaultBase.AUTO_MERGE,
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 b726292..77a51db 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
@@ -70,6 +70,8 @@
 const DETAILS_QUOTA: Map<RunStatus | Category, number> = new Map();
 DETAILS_QUOTA.set(Category.ERROR, 7);
 DETAILS_QUOTA.set(Category.WARNING, 2);
+DETAILS_QUOTA.set(Category.INFO, 2);
+DETAILS_QUOTA.set(Category.SUCCESS, 2);
 DETAILS_QUOTA.set(RunStatus.RUNNING, 2);
 
 @customElement('gr-change-summary')
@@ -417,8 +419,27 @@
       if (hasResultsOf(run, category)) return true;
       return category === Category.SUCCESS && hasCompletedWithoutResults(run);
     });
+    const hasRunning = this.runs.some(isRunningOrScheduled);
+    const hasWarning = this.runs.some(run =>
+      hasResultsOf(run, Category.WARNING)
+    );
+    const hasError = this.runs.some(run => hasResultsOf(run, Category.ERROR));
     const count = (run: CheckRun) => getResultsOf(run, category);
-    if (category === Category.SUCCESS || category === Category.INFO) {
+
+    // Sometimes INFO and SUCCESS results should not consume much UI space and
+    // not grab any attention, e.g. when there are errors. Then let's
+    // aggressively collapse them into one small chip. But if INFO and SUCCESS
+    // is all we have, then make use of the one line we have and show expanded
+    // chips.
+    if (
+      category === Category.SUCCESS &&
+      (hasRunning || hasError || hasWarning || runs.length > 3)
+    ) {
+      return this.renderChecksChipsCollapsed(runs, category, count);
+    } else if (
+      category === Category.INFO &&
+      (hasRunning || hasError || runs.length > 3)
+    ) {
       return this.renderChecksChipsCollapsed(runs, category, count);
     }
     return this.renderChecksChipsExpanded(runs, category);
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index 05036ab..3c5371f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -6,13 +6,15 @@
 import '../../../test/common-test-setup';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrChangeSummary} from './gr-change-summary';
-import {queryAndAssert} from '../../../utils/common-util';
+import {queryAll, queryAndAssert} from '../../../utils/common-util';
 import {fakeRun0} from '../../../models/checks/checks-fakes';
 import {
   createAccountWithEmail,
+  createCheckResult,
   createComment,
   createCommentThread,
   createDraft,
+  createRun,
 } from '../../../test/test-data-generators';
 import {stubFlags} from '../../../test/test-utils';
 import {Timestamp} from '../../../api/rest-api';
@@ -22,6 +24,9 @@
   CommentsModel,
   commentsModelToken,
 } from '../../../models/comments/comments-model';
+import {GrChecksChip} from './gr-checks-chip';
+import {CheckRun} from '../../../models/checks/checks-model';
+import {Category, RunStatus} from '../../../api/checks';
 
 suite('gr-change-summary test', () => {
   let element: GrChangeSummary;
@@ -117,6 +122,73 @@
     );
   });
 
+  suite('checks summary', () => {
+    const checkSummary = async (runs: CheckRun[], texts: string[]) => {
+      element.runs = runs;
+      element.showChecksSummary = true;
+      await element.updateComplete;
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip') ?? [];
+      assert.deepEqual(
+        [...chips].map(c => `${c.statusOrCategory} ${c.text}`),
+        texts
+      );
+    };
+
+    test('single success', async () => {
+      checkSummary([createRun()], ['SUCCESS test-name']);
+    });
+
+    test('single running', async () => {
+      checkSummary(
+        [createRun({status: RunStatus.RUNNING})],
+        ['RUNNING test-name']
+      );
+    });
+
+    test('single info', async () => {
+      checkSummary(
+        [
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.INFO})],
+          }),
+        ],
+        ['INFO test-name']
+      );
+    });
+
+    test('single of each collapses INFO and SUCCESS', async () => {
+      checkSummary(
+        [
+          createRun({status: RunStatus.RUNNING}),
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.SUCCESS})],
+          }),
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.INFO})],
+          }),
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.WARNING})],
+          }),
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.ERROR})],
+          }),
+        ],
+        [
+          'ERROR test-name',
+          'WARNING test-name',
+          'INFO 1',
+          'SUCCESS 1',
+          'RUNNING test-name',
+        ]
+      );
+    });
+  });
+
   test('renders mentions summary', async () => {
     stubFlags('isEnabled').returns(true);
     // recreate element so that flag protected subscriptions are added
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 371e5b5..721d650 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
@@ -115,7 +115,6 @@
 import {GrFileList} from '../gr-file-list/gr-file-list';
 import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {
-  CloseFixPreviewEvent,
   EditableContentSaveEvent,
   EventType,
   OpenFixPreviewEvent,
@@ -590,8 +589,9 @@
     this.addEventListener('editable-content-cancel', () =>
       this.handleCommitMessageCancel()
     );
-    this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', e => this.onCloseFixPreview(e));
+    this.addEventListener(EventType.OPEN_FIX_PREVIEW, e =>
+      this.onOpenFixPreview(e)
+    );
 
     this.addEventListener(EventType.SHOW_TAB, e => this.setActiveTab(e));
     this.addEventListener('reload', e => {
@@ -1675,10 +1675,6 @@
     this.applyFixDialog.open(e);
   }
 
-  private onCloseFixPreview(e: CloseFixPreviewEvent) {
-    if (e.detail.fixApplied) fireReload(this);
-  }
-
   // Private but used in tests.
   handleToggleDiffMode() {
     if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
@@ -2080,13 +2076,7 @@
   // Private but used in tests.
   viewStateChanged() {
     if (!this.viewState) return;
-
-    if (this.isChangeObsolete()) {
-      // Tell the app element that we are not going to handle the new change
-      // number and that they have to create a new change view.
-      fireEvent(this, EventType.RECREATE_CHANGE_VIEW);
-      return;
-    }
+    if (this.isChangeObsolete()) return;
 
     if (this.viewState.basePatchNum === undefined)
       this.viewState.basePatchNum = PARENT;
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 9b78e64..ac4c1f9 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
@@ -1557,21 +1557,6 @@
     assert.isOk(element.patchRange?.patchNum);
   });
 
-  test('do not handle new change numbers', async () => {
-    const recreateSpy = sinon.spy();
-    element.addEventListener('recreate-change-view', recreateSpy);
-
-    const value: ChangeViewState = createChangeViewState();
-    element.viewState = value;
-    await element.updateComplete;
-    assert.isFalse(recreateSpy.calledOnce);
-
-    value.changeNum = 555111333 as NumericChangeId;
-    element.viewState = {...value};
-    await element.updateComplete;
-    assert.isTrue(recreateSpy.calledOnce);
-  });
-
   test('related changes are updated when loadData is called', async () => {
     await element.updateComplete;
     const relatedChanges = element.shadowRoot!.querySelector(
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 1fbddc5..247b7c3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -83,9 +83,6 @@
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
-      stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
-        Promise.resolve()
-      );
       stubElement('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
       stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
 
@@ -2071,9 +2068,6 @@
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
-        Promise.resolve()
-      );
       stubRestApi('getDiff').callsFake(() => Promise.resolve(createDiff()));
       stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 4f7d56f..997d9d5 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -1310,15 +1310,17 @@
     const repo = ctx.params[0] as RepoName;
     const commentId = ctx.params[2] as UrlEncodedCommentId;
 
-    const [comments, robotComments, change] = await Promise.all([
+    const [comments, robotComments, drafts, change] = await Promise.all([
       this.restApiService.getDiffComments(changeNum),
       this.restApiService.getDiffRobotComments(changeNum),
+      this.restApiService.getDiffDrafts(changeNum),
       this.restApiService.getChangeDetail(changeNum),
     ]);
 
     const comment =
       findComment(addPath(comments), commentId) ??
-      findComment(addPath(robotComments), commentId);
+      findComment(addPath(robotComments), commentId) ??
+      findComment(addPath(drafts), commentId);
     const path = comment?.path;
     const patchsets = computeAllPatchSets(change);
     const latestPatchNum = computeLatestPatchNum(patchsets);
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index b146e93..1608c22 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -20,7 +20,6 @@
 import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
 import {OpenFixPreviewEvent} from '../../../types/events';
 import {getAppContext} from '../../../services/app-context';
-import {fireCloseFixPreview} from '../../../utils/event-util';
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
@@ -37,6 +36,8 @@
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {changeModelToken} from '../../../models/change/change-model';
+import {fireReload} from '../../../utils/event-util';
 
 interface FilePreview {
   filepath: string;
@@ -90,12 +91,20 @@
   @state()
   diffPrefs?: DiffPreferencesInfo;
 
+  @state()
+  isOwner = false;
+
+  @state()
+  onCloseFixPreviewCallbacks: ((fixapplied: boolean) => void)[] = [];
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
     resolve(this, highlightServiceToken),
     () => getAppContext().reportingService
@@ -105,6 +114,11 @@
     super();
     subscribe(
       this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+    subscribe(
+      this,
       () => this.getUserModel().preferences$,
       preferences => {
         const layers: DiffLayer[] = [this.syntaxLayer];
@@ -234,6 +248,7 @@
   open(e: OpenFixPreviewEvent) {
     this.patchNum = e.detail.patchNum;
     this.fixSuggestions = e.detail.fixSuggestions;
+    this.onCloseFixPreviewCallbacks = e.detail.onCloseFixPreviewCallbacks;
     assert(this.fixSuggestions.length > 0, 'no fix in the event');
     this.selectedFixIdx = 0;
     this.applyFixModal?.showModal();
@@ -319,12 +334,14 @@
     this.currentPreviews = [];
     this.isApplyFixLoading = false;
 
-    fireCloseFixPreview(this, fixApplied);
+    this.onCloseFixPreviewCallbacks.forEach(fn => fn(fixApplied));
     this.applyFixModal?.close();
+    if (fixApplied) fireReload(this);
   }
 
   private computeTooltip() {
     if (!this.change || !this.patchNum) return '';
+    if (!this.isOwner) return 'Fix can only be applied by author';
     const latestPatchNum =
       this.change.revisions[this.change.current_revision]._number;
     return latestPatchNum !== this.patchNum
@@ -334,6 +351,7 @@
 
   private computeDisableApplyFixButton() {
     if (!this.change || !this.patchNum) return true;
+    if (!this.isOwner) return true;
     const latestPatchNum =
       this.change.revisions[this.change.current_revision]._number;
     return this.patchNum !== latestPatchNum || this.isApplyFixLoading;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 24dadf7..9284fb2 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -17,11 +17,7 @@
 } from '../../../test/test-data-generators';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {DiffInfo} from '../../../types/diff';
-import {
-  CloseFixPreviewEventDetail,
-  EventType,
-  OpenFixPreviewEventDetail,
-} from '../../../types/events';
+import {EventType, OpenFixPreviewEventDetail} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
 import {SinonStub} from 'sinon';
@@ -37,11 +33,13 @@
       createFixSuggestionInfo('fix_1'),
       createFixSuggestionInfo('fix_2'),
     ],
+    onCloseFixPreviewCallbacks: [],
   };
 
   const ONE_FIX: OpenFixPreviewEventDetail = {
     patchNum: 2 as PatchSetNum,
     fixSuggestions: [createFixSuggestionInfo('fix_1')],
+    onCloseFixPreviewCallbacks: [],
   };
 
   function getConfirmButton(): GrButton {
@@ -73,6 +71,7 @@
     element.changeNum = change._number;
     element.patchNum = change.revisions[change.current_revision]._number;
     element.change = change;
+    element.isOwner = true;
     element.diffPrefs = {
       ...createDefaultDiffPrefs(),
       font_size: 12,
@@ -162,8 +161,22 @@
       assert.equal(button.getAttribute('title'), '');
     });
 
+    test('apply fix button is disabled for non-author', async () => {
+      element.isOwner = false;
+      await element.updateComplete;
+      await open(TWO_FIXES);
+      assert.equal(element.currentFix!.fix_id, 'fix_1');
+      assert.equal(element.currentPreviews.length, 2);
+      const button = getConfirmButton();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.equal(
+        button.getAttribute('title'),
+        'Fix can only be applied by author'
+      );
+    });
+
     test('apply fix button is disabled on older patchset', async () => {
-      element.change = element.change = {
+      element.change = {
         ...createParsedChange(),
         revisions: createRevisions(2),
         current_revision: getCurrentRevision(0),
@@ -246,11 +259,7 @@
     element.currentFix = createFixSuggestionInfo('123');
 
     const closeFixPreviewEventSpy = sinon.spy();
-    // Element is recreated after each test, removeEventListener isn't required
-    element.addEventListener(
-      EventType.CLOSE_FIX_PREVIEW,
-      closeFixPreviewEventSpy
-    );
+    element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
 
     await element.handleApplyFix(new CustomEvent('confirm'));
 
@@ -263,14 +272,7 @@
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/2..edit');
 
-    sinon.assert.calledOnceWithExactly(
-      closeFixPreviewEventSpy,
-      new CustomEvent<CloseFixPreviewEventDetail>(EventType.CLOSE_FIX_PREVIEW, {
-        detail: {
-          fixApplied: true,
-        },
-      })
-    );
+    sinon.assert.calledOnceWithExactly(closeFixPreviewEventSpy, true);
     // reset gr-apply-fix-dialog and close
     assert.equal(element.currentFix, undefined);
     assert.equal(element.currentPreviews.length, 0);
@@ -311,11 +313,7 @@
     element.currentFix = createFixSuggestionInfo('fix_123');
 
     const closeFixPreviewEventSpy = sinon.spy();
-    // Element is recreated after each test, removeEventListener isn't required
-    element.addEventListener(
-      EventType.CLOSE_FIX_PREVIEW,
-      closeFixPreviewEventSpy
-    );
+    element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
 
     let expectedError;
     await element.handleApplyFix(new CustomEvent('click')).catch(e => {
@@ -328,19 +326,8 @@
 
   test('onCancel fires close with correct parameters', () => {
     const closeFixPreviewEventSpy = sinon.spy();
-    // Element is recreated after each test, removeEventListener isn't required
-    element.addEventListener(
-      EventType.CLOSE_FIX_PREVIEW,
-      closeFixPreviewEventSpy
-    );
+    element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
     element.onCancel(new CustomEvent('cancel'));
-    sinon.assert.calledOnceWithExactly(
-      closeFixPreviewEventSpy,
-      new CustomEvent<CloseFixPreviewEventDetail>(EventType.CLOSE_FIX_PREVIEW, {
-        detail: {
-          fixApplied: false,
-        },
-      })
-    );
+    sinon.assert.calledOnceWithExactly(closeFixPreviewEventSpy, false);
   });
 });
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 c74993f..c7d6948 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
@@ -53,11 +53,7 @@
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {CommentMap} from '../../../utils/comment-util';
-import {
-  EventType,
-  OpenFixPreviewEvent,
-  ValueChangedEvent,
-} from '../../../types/events';
+import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 import {Key, toggleClass, whenVisible} from '../../../utils/dom-util';
@@ -417,14 +413,12 @@
       changeNum => {
         if (!changeNum || this.changeNum === changeNum) return;
 
-        // We are only setting the changeNum of the diff view once!
+        // We are only setting the changeNum of the diff view once.
         // Everything in the diff view is tied to the change. It seems better to
         // force the re-creation of the diff view when the change number changes.
-        if (!this.changeNum) {
-          this.changeNum = changeNum;
-        } else {
-          fireEvent(this, EventType.RECREATE_DIFF_VIEW);
-        }
+        // The parent element will make sure that a new change view is created
+        // when the change number changes (using the `keyed` directive).
+        if (!this.changeNum) this.changeNum = changeNum;
       }
     );
     subscribe(
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index eceb05e..b096f76 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -32,7 +32,7 @@
 import {navigationToken} from './core/gr-navigation/gr-navigation';
 import {getAppContext} from '../services/app-context';
 import {routerToken} from './core/gr-router/gr-router';
-import {AccountDetailInfo} from '../types/common';
+import {AccountDetailInfo, NumericChangeId} from '../types/common';
 import {
   constructServerErrorMsg,
   GrErrorManager,
@@ -62,6 +62,7 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {Shortcut, ShortcutController} from './lit/shortcut-controller';
 import {cache} from 'lit/directives/cache.js';
+import {keyed} from 'lit/directives/keyed.js';
 import {assertIsDefined} from '../utils/common-util';
 import './gr-css-mixins';
 import {isDarkTheme, prefersDarkColorScheme} from '../utils/theme-util';
@@ -123,6 +124,9 @@
   // TODO: Introduce a wrapper element for CHANGE, DIFF, EDIT view.
   @state() private childView?: ChangeChildView;
 
+  // Used as a key for caching the CHANGE, DIFF, EDIT view.
+  @state() private changeNum?: NumericChangeId;
+
   @state() private lastError?: ErrorInfo;
 
   // private but used in test
@@ -148,12 +152,6 @@
   // (e.g. shortcut dialog) is open
   @state() private mainAriaHidden = false;
 
-  // Triggers dom-if unsetting/setting restamp behaviour in lit
-  @state() private invalidateChangeViewCache = false;
-
-  // Triggers dom-if unsetting/setting restamp behaviour in lit
-  @state() private invalidateDiffViewCache = false;
-
   @state() private theme = AppTheme.AUTO;
 
   readonly getRouter = resolve(this, routerToken);
@@ -189,12 +187,6 @@
     document.addEventListener(EventType.LOCATION_CHANGE, () =>
       this.handleLocationChange()
     );
-    this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
-      this.handleRecreateView()
-    );
-    this.addEventListener(EventType.RECREATE_DIFF_VIEW, () =>
-      this.handleRecreateView()
-    );
     document.addEventListener(EventType.GR_RPC_LOG, e => this.handleRpcLog(e));
     this.shortcuts.addAbstract(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, () =>
       this.showKeyboardShortcuts()
@@ -246,8 +238,13 @@
     subscribe(
       this,
       () => this.getChangeViewModel().childView$,
-      childView => {
-        this.childView = childView;
+      childView => (this.childView = childView)
+    );
+    subscribe(
+      this,
+      () => this.getChangeViewModel().changeNum$,
+      changeNum => {
+        this.changeNum = changeNum;
       }
     );
 
@@ -375,8 +372,19 @@
       ${this.renderHeader()}
       <main ?aria-hidden=${this.mainAriaHidden}>
         ${this.renderMobileSearch()} ${this.renderChangeListView()}
-        ${this.renderDashboardView()} ${this.renderChangeView()}
-        ${this.renderEditorView()} ${this.renderDiffView()}
+        ${this.renderDashboardView()}
+        ${
+          // `keyed(this.changeNum, ...)` makes sure that these views are not
+          // re-used across changes, which is a precaution, because we have run
+          // into issue with that. That could be re-considered at some point.
+          keyed(
+            this.changeNum,
+            html`
+              ${this.renderChangeView()} ${this.renderEditorView()}
+              ${this.renderDiffView()}
+            `
+          )
+        }
         ${this.renderSettingsView()} ${this.renderAdminView()}
         ${this.renderPluginScreen()} ${this.renderCLAView()}
         ${this.renderDocumentationSearch()}
@@ -473,18 +481,15 @@
   }
 
   private renderChangeView() {
-    if (this.invalidateChangeViewCache) {
-      this.updateComplete.then(() => (this.invalidateChangeViewCache = false));
-      return nothing;
-    }
-    return cache(this.isChangeView() ? this.changeViewTemplate() : nothing);
-  }
-
-  // Template as not to create duplicates, for renderChangeView() only.
-  private changeViewTemplate() {
-    return html`
-      <gr-change-view .backPage=${this.lastSearchPage}></gr-change-view>
-    `;
+    // The `cache()` is required for re-using the change view when switching
+    // back and forth between change, diff and editor views.
+    return cache(
+      this.isChangeView()
+        ? html`<gr-change-view
+            .backPage=${this.lastSearchPage}
+          ></gr-change-view>`
+        : nothing
+    );
   }
 
   private isChangeView() {
@@ -494,9 +499,11 @@
     );
   }
 
-  private isDiffView() {
-    return (
-      this.view === GerritView.CHANGE && this.childView === ChangeChildView.DIFF
+  private renderEditorView() {
+    // The `cache()` is required for re-using the editor view when switching
+    // back and forth between change, diff and editor views.
+    return cache(
+      this.isEditorView() ? html`<gr-editor-view></gr-editor-view>` : nothing
     );
   }
 
@@ -506,21 +513,18 @@
     );
   }
 
-  private renderEditorView() {
-    if (!this.isEditorView()) return nothing;
-    return html`<gr-editor-view></gr-editor-view>`;
-  }
-
   private renderDiffView() {
-    if (this.invalidateDiffViewCache) {
-      this.updateComplete.then(() => (this.invalidateDiffViewCache = false));
-      return nothing;
-    }
-    return cache(this.isDiffView() ? this.diffViewTemplate() : nothing);
+    // The `cache()` is required for re-using the diff view when switching
+    // back and forth between change, diff and editor views.
+    return cache(
+      this.isDiffView() ? html`<gr-diff-view></gr-diff-view>` : nothing
+    );
   }
 
-  private diffViewTemplate() {
-    return html`<gr-diff-view></gr-diff-view>`;
+  private isDiffView() {
+    return (
+      this.view === GerritView.CHANGE && this.childView === ChangeChildView.DIFF
+    );
   }
 
   private renderSettingsView() {
@@ -619,15 +623,6 @@
         (this.account && this.account._account_id) || null;
   }
 
-  /**
-   * Throws away the view and re-creates it. The view itself fires an event, if
-   * it wants to be re-created.
-   */
-  private handleRecreateView() {
-    this.invalidateChangeViewCache = true;
-    this.invalidateDiffViewCache = true;
-  }
-
   private async viewChanged() {
     if (
       this.params &&
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 1ac89d6..c844d42 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -1053,6 +1053,11 @@
           replacement
         ),
         patchNum: this.comment.patch_set,
+        onCloseFixPreviewCallbacks: [
+          fixApplied => {
+            if (fixApplied) this.handleAppliedFix();
+          },
+        ],
       };
     }
     if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
@@ -1065,6 +1070,7 @@
           };
         }),
         patchNum: this.comment.patch_set,
+        onCloseFixPreviewCallbacks: [],
       };
     }
     throw new Error('unable to create preview fix event');
@@ -1132,6 +1138,18 @@
     fire(this, 'reply-to-comment', eventDetail);
   }
 
+  private handleAppliedFix() {
+    const message = this.comment?.message;
+    assert(!!message, 'empty message');
+    const eventDetail: ReplyToCommentEventDetail = {
+      content: 'Fix applied.',
+      userWantsToEdit: false,
+      unresolved: false,
+    };
+    // Handled by <gr-comment-thread>.
+    fire(this, 'reply-to-comment', eventDetail);
+  }
+
   private async handleShowFix() {
     // Handled top-level in the diff and change view components.
     fire(this, 'open-fix-preview', await this.createFixPreview());
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index 25ee130..05b7fb5 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -18,8 +18,10 @@
 } from '../../../utils/date-util';
 import {TimeFormat, DateFormat} from '../../../constants/constants';
 import {assertNever} from '../../../utils/common-util';
-import {Timestamp} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
+import {PreferencesInfo, Timestamp} from '../../../types/common';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 const TimeFormats = {
   TIME_12: 'h:mm A', // 2:14 PM
@@ -95,7 +97,7 @@
   @state()
   relative = false;
 
-  private readonly restApiService = getAppContext().restApiService;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   static override get styles() {
     return [
@@ -108,17 +110,30 @@
     ];
   }
 
-  override render() {
-    if (!this.withTooltip) {
-      return this.renderDateString();
-    }
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => this.setPreferences(prefs)
+    );
+  }
 
-    const fullDateStr = this.computeFullDateStr();
-    if (!fullDateStr) {
-      return this.renderDateString();
-    }
+  // private but used by tests
+  setPreferences(prefs: PreferencesInfo) {
+    this.decideDateFormat(prefs.date_format);
+    this.decideTimeFormat(prefs.time_format);
+    this.relative =
+      this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
+  }
+
+  override render() {
+    if (!this.withTooltip) return this.renderDateString();
+    const tooltip = this.computeFullDateStr();
+    if (!tooltip) return this.renderDateString();
+
     return html`
-      <gr-tooltip-content has-tooltip title=${fullDateStr}>
+      <gr-tooltip-content has-tooltip title=${tooltip}>
         ${this.renderDateString()}
       </gr-tooltip-content>
     `;
@@ -128,38 +143,11 @@
     return html` <span>${this.computeDateStr()}</span>`;
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.loadPreferences();
-  }
-
   // private but used by tests
-  _getUtcOffsetString() {
+  getUtcOffsetString() {
     return utcOffsetString();
   }
 
-  // private but used by tests
-  async loadPreferences() {
-    const loggedIn = await this.restApiService.getLoggedIn();
-    if (!loggedIn) {
-      this.timeFormat = TimeFormats.TIME_24;
-      this.dateFormat = DateFormats.STD;
-      this.relative = this.forceRelative;
-      return;
-    }
-    await Promise.all([this.loadTimeFormat(), this.loadRelative()]);
-  }
-
-  // private but used in gr/file-list_test.ts
-  async loadTimeFormat() {
-    const preferences = await this.restApiService.getPreferences();
-    if (!preferences) {
-      throw Error('Preferences is not set');
-    }
-    this.decideTimeFormat(preferences.time_format);
-    this.decideDateFormat(preferences.date_format);
-  }
-
   private decideTimeFormat(timeFormat: TimeFormat) {
     switch (timeFormat) {
       case TimeFormat.HHMM_12:
@@ -195,12 +183,6 @@
     }
   }
 
-  private async loadRelative() {
-    const prefs = await this.restApiService.getPreferences();
-    this.relative =
-      this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
-  }
-
   private computeDateStr() {
     if (!this.dateStr || !this.timeFormat || !this.dateFormat) {
       return '';
@@ -222,33 +204,24 @@
       if (isWithinHalfYear(now, date)) {
         format = this.dateFormat.short;
       }
-      if (this.showDateAndTime || this.showDateAndTime) {
+      if (this.showDateAndTime) {
         format = `${format} ${this.timeFormat}`;
       }
     }
     return formatDate(date, format);
   }
 
-  private computeFullDateStr() {
-    if (
-      [this.dateStr, this.timeFormat].includes(undefined) ||
-      !this.dateFormat
-    ) {
-      return undefined;
-    }
-
-    if (!this.dateStr) {
-      return '';
-    }
+  private computeFullDateStr(): string {
+    if (!this.dateStr) return '';
+    if (!this.timeFormat) return '';
+    if (!this.dateFormat) return '';
     const date = parseDate(this.dateStr as Timestamp);
-    if (!isValidDate(date)) {
-      return '';
-    }
-    let format = this.dateFormat.full + ', ';
-    format +=
+    if (!isValidDate(date)) return '';
+    const timeFormat =
       this.timeFormat === TimeFormats.TIME_12
         ? TimeFormats.TIME_12_WITH_SEC
         : TimeFormats.TIME_24_WITH_SEC;
-    return formatDate(date, format) + this._getUtcOffsetString();
+    const format = `dddd, ${this.dateFormat.full}, ${timeFormat}`;
+    return formatDate(date, format) + this.getUtcOffsetString();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
index d7c38df..98f2d82 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -8,16 +8,15 @@
 import {GrDateFormatter} from './gr-date-formatter';
 import {parseDate} from '../../../utils/date-util';
 import {fixture, html, assert} from '@open-wc/testing';
-import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {query, queryAndAssert} from '../../../test/test-utils';
 import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
 import {Timestamp} from '../../../api/rest-api';
-import {PreferencesInfo} from '../../../types/common';
-import {createPreferences} from '../../../test/test-data-generators';
 import {
   createDefaultPreferences,
   DateFormat,
   TimeFormat,
 } from '../../../constants/constants';
+import {PreferencesInfo} from '../../../types/common';
 
 const basicTemplate = html`
   <gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
@@ -41,6 +40,10 @@
     return d;
   }
 
+  function setPrefs(prefs: Partial<PreferencesInfo>) {
+    element.setPreferences({...createDefaultPreferences(), ...prefs});
+  }
+
   async function testDates(
     nowStr: string,
     dateStr: string,
@@ -68,23 +71,11 @@
     assert.equal(span.textContent?.trim(), expectedWithDateAndTime);
   }
 
-  function stubRestAPI(preferences?: PreferencesInfo) {
-    stubRestApi('getLoggedIn').resolves(preferences !== undefined);
-    stubRestApi('getPreferences').resolves(preferences);
-  }
-
   suite('STD + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.STD,
-        relative_date_in_change_table: false,
-      });
-
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.STD, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -93,7 +84,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        'Jul 29, 2015, 15:34:14'
+        'Wednesday, Jul 29, 2015, 15:34:14'
       );
     });
 
@@ -103,7 +94,7 @@
         '2015-07-28 20:25:14.985000000',
         'Jul 28',
         'Jul 28 20:25',
-        'Jul 28, 2015, 20:25:14'
+        'Tuesday, Jul 28, 2015, 20:25:14'
       );
     });
 
@@ -113,7 +104,7 @@
         '2015-06-15 03:25:14.985000000',
         'Jun 15',
         'Jun 15 03:25',
-        'Jun 15, 2015, 03:25:14'
+        'Monday, Jun 15, 2015, 03:25:14'
       );
     });
 
@@ -123,22 +114,16 @@
         '2015-01-15 03:25:00.000000000',
         'Jan 15, 2015',
         'Jan 15, 2015 03:25',
-        'Jan 15, 2015, 03:25:00'
+        'Thursday, Jan 15, 2015, 03:25:00'
       );
     });
   });
 
   suite('US + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.US,
-        relative_date_in_change_table: false,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.US, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -147,7 +132,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        '07/29/15, 15:34:14'
+        'Wednesday, 07/29/15, 15:34:14'
       );
     });
 
@@ -157,7 +142,7 @@
         '2015-07-28 20:25:14.985000000',
         '07/28',
         '07/28 20:25',
-        '07/28/15, 20:25:14'
+        'Tuesday, 07/28/15, 20:25:14'
       );
     });
 
@@ -167,23 +152,16 @@
         '2015-06-15 03:25:14.985000000',
         '06/15',
         '06/15 03:25',
-        '06/15/15, 03:25:14'
+        'Monday, 06/15/15, 03:25:14'
       );
     });
   });
 
   suite('ISO + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.ISO,
-        relative_date_in_change_table: false,
-      });
-
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.ISO, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -192,7 +170,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        '2015-07-29, 15:34:14'
+        'Wednesday, 2015-07-29, 15:34:14'
       );
     });
 
@@ -202,7 +180,7 @@
         '2015-07-28 20:25:14.985000000',
         '07-28',
         '07-28 20:25',
-        '2015-07-28, 20:25:14'
+        'Tuesday, 2015-07-28, 20:25:14'
       );
     });
 
@@ -212,23 +190,16 @@
         '2015-06-15 03:25:14.985000000',
         '06-15',
         '06-15 03:25',
-        '2015-06-15, 03:25:14'
+        'Monday, 2015-06-15, 03:25:14'
       );
     });
   });
 
   suite('EURO + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.EURO,
-        relative_date_in_change_table: false,
-      });
-
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.EURO, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -237,7 +208,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        '29.07.2015, 15:34:14'
+        'Wednesday, 29.07.2015, 15:34:14'
       );
     });
 
@@ -247,7 +218,7 @@
         '2015-07-28 20:25:14.985000000',
         '28. Jul',
         '28. Jul 20:25',
-        '28.07.2015, 20:25:14'
+        'Tuesday, 28.07.2015, 20:25:14'
       );
     });
 
@@ -257,23 +228,16 @@
         '2015-06-15 03:25:14.985000000',
         '15. Jun',
         '15. Jun 03:25',
-        '15.06.2015, 03:25:14'
+        'Monday, 15.06.2015, 03:25:14'
       );
     });
   });
 
   suite('UK + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.UK,
-        relative_date_in_change_table: false,
-      });
-
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.UK, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -282,7 +246,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        '29/07/2015, 15:34:14'
+        'Wednesday, 29/07/2015, 15:34:14'
       );
     });
 
@@ -292,7 +256,7 @@
         '2015-07-28 20:25:14.985000000',
         '28/07',
         '28/07 20:25',
-        '28/07/2015, 20:25:14'
+        'Tuesday, 28/07/2015, 20:25:14'
       );
     });
 
@@ -302,22 +266,16 @@
         '2015-06-15 03:25:14.985000000',
         '15/06',
         '15/06 03:25',
-        '15/06/2015, 03:25:14'
+        'Monday, 15/06/2015, 03:25:14'
       );
     });
   });
 
   suite('STD + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.STD,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.STD, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -326,22 +284,16 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        'Jul 29, 2015, 3:34:14 PM'
+        'Wednesday, Jul 29, 2015, 3:34:14 PM'
       );
     });
   });
 
   suite('US + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.US,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.US, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -350,22 +302,16 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        '07/29/15, 3:34:14 PM'
+        'Wednesday, 07/29/15, 3:34:14 PM'
       );
     });
   });
 
   suite('ISO + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.ISO,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.ISO, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -374,22 +320,16 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        '2015-07-29, 3:34:14 PM'
+        'Wednesday, 2015-07-29, 3:34:14 PM'
       );
     });
   });
 
   suite('EURO + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.EURO,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.EURO, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -398,22 +338,16 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        '29.07.2015, 3:34:14 PM'
+        'Wednesday, 29.07.2015, 3:34:14 PM'
       );
     });
   });
 
   suite('UK + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.UK,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.UK, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -422,22 +356,20 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        '29/07/2015, 3:34:14 PM'
+        'Wednesday, 29/07/2015, 3:34:14 PM'
       );
     });
   });
 
   suite('relative date preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
+      element = await fixture(basicTemplate);
+      setPrefs({
         date_format: DateFormat.STD,
+        time_format: TimeFormat.HHMM_12,
         relative_date_in_change_table: true,
       });
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -446,7 +378,7 @@
         '2015-07-29 15:34:14.985000000',
         '5 hours ago',
         '5 hours ago',
-        'Jul 29, 2015, 3:34:14 PM'
+        'Wednesday, Jul 29, 2015, 3:34:14 PM'
       );
     });
 
@@ -456,21 +388,19 @@
         '2015-01-15 03:25:00.000000000',
         '8 months ago',
         '8 months ago',
-        'Jan 15, 2015, 3:25:00 AM'
+        'Thursday, Jan 15, 2015, 3:25:00 AM'
       );
     });
   });
 
   suite('logged in', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
+      element = await fixture(basicTemplate);
+      setPrefs({
         date_format: DateFormat.US,
+        time_format: TimeFormat.HHMM_12,
         relative_date_in_change_table: true,
       });
-      element = await fixture(basicTemplate);
-      await element.loadPreferences();
     });
 
     test('Preferences are respected', () => {
@@ -483,13 +413,12 @@
 
   suite('logged out', () => {
     setup(async () => {
-      stubRestAPI(undefined);
       element = await fixture(basicTemplate);
-      await element.loadPreferences();
+      setPrefs({});
     });
 
     test('Default preferences are respected', () => {
-      assert.equal(element.timeFormat, 'HH:mm');
+      assert.equal(element.timeFormat, 'h:mm A');
       assert.equal(element.dateFormat?.short, 'MMM DD');
       assert.equal(element.dateFormat?.full, 'MMM DD, YYYY');
       assert.isFalse(element.relative);
@@ -498,9 +427,8 @@
 
   suite('with tooltip', () => {
     setup(async () => {
-      stubRestAPI(createDefaultPreferences());
       element = await fixture(basicTemplate);
-      await element.loadPreferences();
+      setPrefs({});
       await element.updateComplete;
     });
 
@@ -515,9 +443,8 @@
 
   suite('without tooltip', () => {
     setup(async () => {
-      stubRestAPI(createDefaultPreferences());
       element = await fixture(lightTemplate);
-      await element.loadPreferences();
+      setPrefs({});
       await element.updateComplete;
     });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index b952a3d..d40fdda 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -26,6 +26,7 @@
 import '../gr-context-controls/gr-context-controls';
 import '../gr-range-header/gr-range-header';
 import './gr-diff-row';
+import {when} from 'lit/directives/when.js';
 
 @customElement('gr-diff-section')
 export class GrDiffSection extends LitElement {
@@ -170,7 +171,7 @@
     `;
     const moveCell = html`
       <td class=${diffClasses('moveHeader')}>
-        <gr-range-header class=${diffClasses()} icon="gr-icons:move-item">
+        <gr-range-header class=${diffClasses()} icon="move_item">
           ${this.renderMoveDescription(movedIn)}
         </gr-range-header>
       </td>
@@ -179,8 +180,13 @@
       <tr
         class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
       >
-        ${lineNumberCell} ${signCell} ${movedIn ? plainCell : moveCell}
-        ${lineNumberCell} ${signCell} ${movedIn ? moveCell : plainCell}
+        ${when(
+          this.isUnifiedDiff(),
+          () => html`${lineNumberCell} ${lineNumberCell} ${moveCell}`,
+          () => html`${lineNumberCell} ${signCell}
+          ${movedIn ? plainCell : moveCell} ${lineNumberCell} ${signCell}
+          ${movedIn ? moveCell : plainCell}`
+        )}
       </tr>
     `;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
index 33b3df0..14f4536 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -9,7 +9,8 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {GrDiffLineType} from '../../../api/diff';
+import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
+import {waitQueryAndAssert} from '../../../test/test-utils';
 
 suite('gr-diff-section test', () => {
   let element: GrDiffSection;
@@ -22,6 +23,93 @@
     await element.updateComplete;
   });
 
+  suite('move controls', async () => {
+    setup(async () => {
+      const lines = [new GrDiffLine(GrDiffLineType.BOTH, 1, 1)];
+      lines[0].text = 'asdf';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines,
+        moveDetails: {changed: false, range: {start: 1, end: 2}},
+      });
+      element.group = group;
+      await element.updateComplete;
+    });
+
+    test('side-by-side', async () => {
+      const row = await waitQueryAndAssert(element, 'tr.moveControls');
+      // Semantic dom diff has a problem with just comparing table rows or
+      // cells directly. So as a workaround put the row into an empty test
+      // table.
+      const testTable = document.createElement('table');
+      testTable.appendChild(row);
+      assert.dom.equal(
+        testTable,
+        /* HTML */ `
+          <table>
+            <tbody>
+              <tr class="gr-diff moveControls movedOut">
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff sign"></td>
+                <td class="gr-diff moveHeader">
+                  <gr-range-header class="gr-diff" icon="move_item">
+                    <div class="gr-diff">
+                      <span class="gr-diff"> Moved to lines </span>
+                      <a class="gr-diff" href="#1"> 1 </a>
+                      <span class="gr-diff"> - </span>
+                      <a class="gr-diff" href="#2"> 2 </a>
+                    </div>
+                  </gr-range-header>
+                </td>
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff sign"></td>
+                <td class="gr-diff"></td>
+              </tr>
+            </tbody>
+          </table>
+        `,
+        {}
+      );
+    });
+
+    test('unified', async () => {
+      element.renderPrefs = {
+        ...element.renderPrefs,
+        view_mode: DiffViewMode.UNIFIED,
+      };
+      const row = await waitQueryAndAssert(element, 'tr.moveControls');
+      // Semantic dom diff has a problem with just comparing table rows or
+      // cells directly. So as a workaround put the row into an empty test
+      // table.
+      const testTable = document.createElement('table');
+      testTable.appendChild(row);
+      assert.dom.equal(
+        testTable,
+        /* HTML */ `
+          <table>
+            <tbody>
+              <tr class="gr-diff moveControls movedOut">
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff moveHeader">
+                  <gr-range-header class="gr-diff" icon="move_item">
+                    <div class="gr-diff">
+                      <span class="gr-diff"> Moved to lines </span>
+                      <a class="gr-diff" href="#1"> 1 </a>
+                      <span class="gr-diff"> - </span>
+                      <a class="gr-diff" href="#2"> 2 </a>
+                    </div>
+                  </gr-range-header>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        `,
+        {}
+      );
+    });
+  });
+
   test('3 normal unchanged rows', async () => {
     const lines = [
       new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
diff --git a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts
new file mode 100644
index 0000000..b31197d16
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-range-header';
+import {GrRangeHeader} from './gr-range-header';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-range-header test', () => {
+  let element: GrRangeHeader;
+
+  setup(async () => {
+    element = await fixture<GrRangeHeader>(
+      html`<gr-range-header></gr-range-header>`
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', async () => {
+    element.filled = true;
+    element.icon = 'test-icon';
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="row">
+          <gr-icon
+            aria-hidden="true"
+            class="icon"
+            filled
+            icon="test-icon"
+          ></gr-icon>
+          <slot></slot>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index ba43eb4..da05bbe 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -121,6 +121,7 @@
   const eventDetail: OpenFixPreviewEventDetail = {
     patchNum: result.patchset as PatchSetNumber,
     fixSuggestions,
+    onCloseFixPreviewCallbacks: [],
   };
   return {
     name: 'Show Fix',
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index 1e71bbd..837e362 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -74,7 +74,7 @@
       ...createChangeViewState(),
       repo: 'x+/y+/z+/w' as RepoName,
     };
-    assert.equal(createChangeUrl(state), '/c/x%2B/y%2B/z%2B/w/+/42');
+    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/42');
   });
 
   test('createDiffUrl', () => {
@@ -85,7 +85,7 @@
     };
     assert.equal(
       createDiffUrl(params),
-      '/c/test-project/+/42/12/x%2By/path.cpp'
+      '/c/test-project/+/42/12/x%252By/path.cpp'
     );
 
     window.CANONICAL_PATH = '/base';
@@ -93,10 +93,10 @@
     window.CANONICAL_PATH = undefined;
 
     params.repo = 'test' as RepoName;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%2By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
 
     params.basePatchNum = 6 as BasePatchSetNum;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%2By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
 
     params.diffView = {
       path: 'foo bar/my+file.txt%',
@@ -105,7 +105,7 @@
     delete params.basePatchNum;
     assert.equal(
       createDiffUrl(params),
-      '/c/test/+/42/2/foo+bar/my%2Bfile.txt%2525'
+      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
     );
 
     params.diffView = {
@@ -129,7 +129,7 @@
       repo: 'x+/y' as RepoName,
       diffView: {path: 'x+y/path.cpp'},
     };
-    assert.equal(createDiffUrl(params), '/c/x%2B/y/+/42/12/x%2By/path.cpp');
+    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
   });
 
   test('createEditUrl', () => {
@@ -140,7 +140,7 @@
     };
     assert.equal(
       createEditUrl(params),
-      '/c/test-project/+/42/12/x%2By/path.cpp,edit#31'
+      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
     );
 
     window.CANONICAL_PATH = '/base';
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 a5bc4bf..94e7f79 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -362,6 +362,7 @@
     return Promise.resolve({});
   },
   getPreferences(): Promise<PreferencesInfo | undefined> {
+    // TODO: Use createDefaultPreferences() instead.
     return Promise.resolve(createPreferences());
   },
   getProjectConfig(): Promise<ConfigInfo | undefined> {
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index b0f0dc0..5abac09 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -73,21 +73,17 @@
 import {
   AccountsVisibility,
   AccountTag,
-  AppTheme,
   AuthType,
   ChangeStatus,
   CommentSide,
-  DateFormat,
-  DefaultBase,
+  createDefaultPreferences,
   DefaultDisplayNameConfig,
-  DiffViewMode,
   EmailStrategy,
   InheritedBooleanInfoConfiguredValue,
   MergeabilityComputationBehavior,
   RequirementStatus,
   RevisionKind,
   SubmitType,
-  TimeFormat,
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
 import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
@@ -110,7 +106,7 @@
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
-import {CheckResult, RunResult} from '../models/checks/checks-model';
+import {CheckResult, CheckRun, RunResult} from '../models/checks/checks-model';
 import {Category, RunStatus} from '../api/checks';
 import {DiffInfo} from '../api/diff';
 import {SearchViewState} from '../models/views/search';
@@ -687,18 +683,12 @@
   };
 }
 
-// TODO: Maybe reconcile with createDefaultPreferences() in constants.ts.
+// TODO: Do not change the values of createDefaultPreferences() here.
 export function createPreferences(): PreferencesInfo {
   return {
+    ...createDefaultPreferences(),
     changes_per_page: 10,
-    theme: AppTheme.AUTO,
-    date_format: DateFormat.ISO,
-    time_format: TimeFormat.HHMM_24,
-    diff_view: DiffViewMode.SIDE_BY_SIDE,
-    my: [],
-    change_table: [],
     email_strategy: EmailStrategy.ENABLED,
-    default_base_for_merges: DefaultBase.AUTO_MERGE,
     allow_browser_notifications: true,
   };
 }
@@ -1103,6 +1093,19 @@
   };
 }
 
+export function createRun(partial: Partial<CheckRun> = {}): CheckRun {
+  return {
+    attemptDetails: [],
+    checkName: 'test-name',
+    internalRunId: 'test-internal-run-id',
+    isLatestAttempt: true,
+    isSingleAttempt: true,
+    pluginName: 'test-plugin-name',
+    status: RunStatus.COMPLETED,
+    ...partial,
+  };
+}
+
 export function createRunResult(): RunResult {
   return {
     attemptDetails: [],
@@ -1119,11 +1122,14 @@
   };
 }
 
-export function createCheckResult(): CheckResult {
+export function createCheckResult(
+  partial: Partial<CheckResult> = {}
+): CheckResult {
   return {
     category: Category.ERROR,
     summary: 'error',
     internalResultId: 'test-internal-result-id',
+    ...partial,
   };
 }
 
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index f642af7..e2612b0 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -26,10 +26,7 @@
   MOVED_LINK_CLICKED = 'moved-link-clicked',
   NETWORK_ERROR = 'network-error',
   OPEN_FIX_PREVIEW = 'open-fix-preview',
-  CLOSE_FIX_PREVIEW = 'close-fix-preview',
   PAGE_ERROR = 'page-error',
-  RECREATE_CHANGE_VIEW = 'recreate-change-view',
-  RECREATE_DIFF_VIEW = 'recreate-diff-view',
   RELOAD = 'reload',
   REPLY = 'reply',
   SERVER_ERROR = 'server-error',
@@ -65,7 +62,6 @@
     'line-cursor-moved-out': LineNumberEvent;
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
-    'close-fix-preview': CloseFixPreviewEvent;
     'reply-to-comment': ReplyToCommentEvent;
     /* prettier-ignore */
     'reload': ReloadEvent;
@@ -158,13 +154,10 @@
 export interface OpenFixPreviewEventDetail {
   patchNum: PatchSetNum;
   fixSuggestions: FixSuggestionInfo[];
+  onCloseFixPreviewCallbacks: ((fixapplied: boolean) => void)[];
 }
 export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
 
-export interface CloseFixPreviewEventDetail {
-  fixApplied: boolean;
-}
-export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
 export interface ReplyToCommentEventDetail {
   content: string;
   userWantsToEdit: boolean;
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 72e6cb7..d95d24a 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -83,21 +83,17 @@
   return diff < 180 * Duration.DAY;
 }
 
-// TODO(dmfilippov): TS-Fix review this type. All fields here must be optional,
-// but this require some changes in the code. During JS->TS migration
-// we want to avoid code changes where possible, so for simplicity we
-// define it with almost all fields mandatory
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts
 interface DateTimeFormatParts {
-  year: string;
-  month: string;
-  day: string;
-  hour: string;
-  minute: string;
-  second: string;
-  dayPeriod: string;
-  dayperiod?: string;
-  // Object can have other properties, but our code doesn't use it
-  [key: string]: string | undefined;
+  year?: string;
+  month?: string;
+  day?: string;
+  hour?: string;
+  minute?: string;
+  second?: string;
+  // AM or PM
+  dayPeriod?: string;
+  weekday?: string;
 }
 
 export function formatDate(date: Date, format: string) {
@@ -117,6 +113,14 @@
     }
   }
 
+  if (format.includes('ddd')) {
+    if (format.includes('dddd')) {
+      options.weekday = 'long';
+    } else {
+      options.weekday = 'short';
+    }
+  }
+
   if (format.includes('DD')) {
     options.day = '2-digit';
   }
@@ -146,15 +150,38 @@
     locale = 'en-GB';
   }
 
-  const dtf = new Intl.DateTimeFormat(locale, options);
-  const parts = dtf
-    .formatToParts(date)
-    .filter(o => o.type !== 'literal')
-    .reduce((acc, o: Intl.DateTimeFormatPart) => {
-      acc[o.type] = o.value;
-      return acc;
-    }, {} as DateTimeFormatParts);
-  if (format.includes('YY')) {
+  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts
+  const dtfParts = new Intl.DateTimeFormat(locale, options).formatToParts(date);
+  const parts: DateTimeFormatParts = {};
+  for (const entry of dtfParts) {
+    switch (entry.type) {
+      case 'year':
+        parts.year = entry.value;
+        break;
+      case 'month':
+        parts.month = entry.value;
+        break;
+      case 'day':
+        parts.day = entry.value;
+        break;
+      case 'hour':
+        parts.hour = entry.value;
+        break;
+      case 'minute':
+        parts.minute = entry.value;
+        break;
+      case 'second':
+        parts.second = entry.value;
+        break;
+      case 'dayPeriod':
+        parts.dayPeriod = entry.value;
+        break;
+      case 'weekday':
+        parts.weekday = entry.value;
+        break;
+    }
+  }
+  if (parts.year && format.includes('YY')) {
     if (format.includes('YYYY')) {
       format = format.replace('YYYY', parts.year);
     } else {
@@ -162,41 +189,50 @@
     }
   }
 
-  if (format.includes('DD')) {
+  if (parts.day && format.includes('DD')) {
     format = format.replace('DD', parts.day);
   }
 
-  if (format.includes('HH')) {
+  if (parts.hour && format.includes('HH')) {
     format = format.replace('HH', parts.hour);
   }
 
-  if (format.includes('h')) {
+  if (parts.hour && format.includes('h')) {
     format = format.replace('h', parts.hour);
   }
 
-  if (format.includes('mm')) {
+  if (parts.minute && format.includes('mm')) {
     format = format.replace('mm', parts.minute);
   }
 
-  if (format.includes('ss')) {
+  if (parts.second && format.includes('ss')) {
     format = format.replace('ss', parts.second);
   }
 
-  if (format.includes('A')) {
-    if (parts.dayperiod) {
-      // Workaround for chrome 70 and below
-      format = format.replace('A', parts.dayperiod.toUpperCase());
-    } else {
-      format = format.replace('A', parts.dayPeriod.toUpperCase());
-    }
+  if (parts.dayPeriod && format.includes('A')) {
+    format = format.replace('A', parts.dayPeriod.toUpperCase());
   }
-  if (format.includes('MM')) {
+
+  // Month and weekday must be last, because they will yield characters that
+  // could be interpreted as format strings, e.g. `h` in `Thursday` would
+  // otherwise be replaced by "hours".
+
+  if (parts.month && format.includes('MM')) {
     if (format.includes('MMM')) {
       format = format.replace('MMM', parts.month);
     } else {
       format = format.replace('MM', parts.month);
     }
   }
+
+  if (parts.weekday && format.includes('ddd')) {
+    if (format.includes('dddd')) {
+      format = format.replace('dddd', parts.weekday);
+    } else {
+      format = format.replace('ddd', parts.weekday);
+    }
+  }
+
   return format;
 }
 
diff --git a/polygerrit-ui/app/utils/date-util_test.ts b/polygerrit-ui/app/utils/date-util_test.ts
index 8e802b7..8d16655 100644
--- a/polygerrit-ui/app/utils/date-util_test.ts
+++ b/polygerrit-ui/app/utils/date-util_test.ts
@@ -194,6 +194,18 @@
         )
       );
     });
+
+    test('weekday', () => {
+      assert.equal(
+        '2013-07-03 Wed',
+        formatDate(new Date('Jul 03 2013 12:14:00'), 'YYYY-MM-DD ddd')
+      );
+      assert.equal(
+        '2013-07-03 Wednesday',
+        formatDate(new Date('Jul 03 2013 00:15:00'), 'YYYY-MM-DD dddd')
+      );
+    });
+
     test('h:mm:ss A shows correctly midnight and midday', () => {
       const timeFormat = 'h:mm A';
       assert.equal(
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 714955b..49d5382 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -103,10 +103,6 @@
   fire(target, EventType.SHOW_TAB, detail);
 }
 
-export function fireCloseFixPreview(target: EventTarget, fixApplied: boolean) {
-  fire(target, EventType.CLOSE_FIX_PREVIEW, {fixApplied});
-}
-
 export function fireReload(target: EventTarget, clearPatchset?: boolean) {
   fire(target, EventType.RELOAD, {clearPatchset: !!clearPatchset});
 }
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 5e294cb..af1e32e 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -92,6 +92,9 @@
   // to not double encode *everything* (just for readaiblity and simplicity),
   // but `%` *must* be double encoded.
   let output = url.replaceAll('%', '%25');
+  // `+` also requires double encoding, because `%2B` would be decoded to `+`
+  // and then replaced by ` `.
+  output = output.replaceAll('+', '%2B');
 
   // This escapes ALL characters EXCEPT:
   // A–Z a–z 0–9 - _ . ! ~ * ' ( )
@@ -138,6 +141,10 @@
  * Single decode for URL components. Will decode plus signs ('+') to spaces.
  * Note: because this function decodes once, it is not the inverse of
  * encodeURL.
+ *
+ * This function must only be used for decoding data returned by the REST API.
+ * Don't use it for decoding browser URLs. The only place for decoding browser
+ * URLs must gr-page.ts.
  */
 export function singleDecodeURL(url: string): string {
   const withoutPlus = url.replace(/\+/g, '%20');
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index 16f85dd..7466a90 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -110,6 +110,10 @@
         assert.equal(encodeURL('abc%def'), 'abc%2525def');
       });
 
+      test('double encodes +', () => {
+        assert.equal(encodeURL('abc+def'), 'abc%252Bdef');
+      });
+
       test('does not encode colon and slash', () => {
         assert.equal(encodeURL(':/'), ':/');
       });
diff --git a/proto/entities.proto b/proto/entities.proto
index 0dc6441..f89e0f0 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -45,7 +45,6 @@
   optional string topic = 14;
   optional string original_subject = 17;
   optional string submission_id = 18;
-  optional Account_Id assignee = 19;
   optional bool is_private = 20;
   optional bool work_in_progress = 21;
   optional bool review_started = 22;
@@ -59,6 +58,7 @@
   reserved 11;   // nbrPatchSets
   reserved 15;   // lastSha1MergeTested
   reserved 16;   // mergeable
+  reserved 19;   // assignee
   reserved 101;  // note_db_state
 }
 
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 28904a9..4c21ae6 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -18,7 +18,7 @@
 GITILES_REPO = GERRIT
 
 # When updating Bouncy Castle, also update it in bazlets.
-BC_VERS = "1.64"
+BC_VERS = "1.72"
 HTTPCOMP_VERS = "4.5.2"
 JETTY_VERS = "9.4.49.v20220914"
 BYTE_BUDDY_VERSION = "1.10.7"
@@ -558,20 +558,26 @@
 
     maven_jar(
         name = "bcprov",
-        artifact = "org.bouncycastle:bcprov-jdk15on:" + BC_VERS,
-        sha1 = "1467dac1b787b5ad2a18201c0c281df69882259e",
+        artifact = "org.bouncycastle:bcprov-jdk18on:" + BC_VERS,
+        sha1 = "d8dc62c28a3497d29c93fee3e71c00b27dff41b4",
     )
 
     maven_jar(
         name = "bcpg",
-        artifact = "org.bouncycastle:bcpg-jdk15on:" + BC_VERS,
-        sha1 = "56956a8c63ccadf62e7c678571cf86f30bd84441",
+        artifact = "org.bouncycastle:bcpg-jdk18on:" + BC_VERS,
+        sha1 = "1a36a1740d07869161f6f0d01fae8d72dd1d8320",
     )
 
     maven_jar(
         name = "bcpkix",
-        artifact = "org.bouncycastle:bcpkix-jdk15on:" + BC_VERS,
-        sha1 = "3dac163e20110817d850d17e0444852a6d7d0bd7",
+        artifact = "org.bouncycastle:bcpkix-jdk18on:" + BC_VERS,
+        sha1 = "bb3fdb5162ccd5085e8d7e57fada4d8eaa571f5a",
+    )
+
+    maven_jar(
+        name = "bcutil",
+        artifact = "org.bouncycastle:bcutil-jdk18on:" + BC_VERS,
+        sha1 = "41f19a69ada3b06fa48781120d8bebe1ba955c77",
     )
 
     maven_jar(