Merge "Silence rollup_bundle() during build"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index a9b31b0..7c478ae 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6754,28 +6754,28 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name         ||Description
-|`message`          |optional|
+|Field Name          ||Description
+|`message`           |optional|
 Commit message for the cherry-pick change. If not set, the commit message of
 the cherry-picked commit is used.
-|`destination`      ||Destination branch
-|`base`             |optional|
+|`destination`       ||Destination branch
+|`base`              |optional|
 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
 If set, it must be a merged commit or a change revision on the destination branch.
-|`parent`           |optional, defaults to 1|
+|`parent`            |optional, defaults to 1|
 Number of the parent relative to which the cherry-pick should be considered.
-|`notify`           |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 after the cherry-pick. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`   |optional|
+|`notify_details`    |optional|
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`keep_reviewers`   |optional, defaults to false|
+|`keep_reviewers`    |optional, defaults to false|
 If `true`, carries reviewers and ccs over from original change to newly created one.
-|`allow_conflicts`  |optional, defaults to false|
+|`allow_conflicts`   |optional, defaults to false|
 If `true`, the cherry-pick uses content merge and succeeds also if
 there are conflicts. If there are conflicts the file contents of the
 created change contain git conflict markers to indicate the conflicts.
@@ -6783,7 +6783,7 @@
 `contains_git_conflicts` field in the link:#change-info[ChangeInfo]. If
 there are conflicts the cherry-pick change is marked as
 work-in-progress.
-|`topic`            |optional|
+|`topic`             |optional|
 The topic of the created cherry-picked change. If not set, the default depends
 on the source. If the source is a change with a topic, the resulting topic
 of the cherry-picked change will be {source_change_topic}-{destination_branch}.
@@ -6791,10 +6791,18 @@
 the created change will have no topic.
 If the change already exists, the topic will not change if not set. If set, the
 topic will be overridden.
-|`allow_empty`      |optional, defaults to false|
+|`allow_empty`       |optional, defaults to false|
 If `true`, the cherry-pick succeeds also if the created commit will be empty.
 If `false`, a cherry-pick that would create an empty commit fails without creating
 the commit.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
 |===========================
 
 [[comment-info]]
@@ -7643,22 +7651,30 @@
 === RebaseInput
 The `RebaseInput` entity contains information for changing parent when rebasing.
 
-[options="header",width="50%",cols="1,^1,5"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`base`        |optional|
+|Field Name          ||Description
+|`base`              |optional|
 The new parent revision. This can be a ref or a SHA-1 to a concrete patchset. +
 Alternatively, a change number can be specified, in which case the current
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
-|`allow_conflicts`|optional, defaults to false|
+|`allow_conflicts`   |optional, defaults to false|
 If `true`, the rebase also succeeds if there are conflicts. +
 If there are conflicts the file contents of the rebased patch set contain
 git conflict markers to indicate the conflicts. +
 Callers can find out whether there were conflicts by checking the
 `contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
 If there are conflicts the change is marked as work-in-progress.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
 |===========================
 
 [[related-change-and-commit-info]]
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index fb03bc5..232b2b5 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -31,4 +31,5 @@
   public boolean allowConflicts;
   public String topic;
   public boolean allowEmpty;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index 10559a3..e9b05cc 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.Map;
+
 public class RebaseInput {
   public String base;
 
@@ -24,4 +26,6 @@
    * to indicate the conflicts.
    */
   public boolean allowConflicts;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 9e8d879..6ef7f1e 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -313,6 +313,7 @@
 
   public ChangeInserter setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
     checkState(
         patchSet == null,
         "setValidationOptions(ImmutableListMultimap<String, String>) only valid before creating a"
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index aed1774..fc56e80 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -100,6 +100,7 @@
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private List<String> groups = Collections.emptyList();
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private boolean fireRevisionCreated = true;
   private boolean allowClosed;
   private boolean sendEmail = true;
@@ -184,6 +185,13 @@
     return this;
   }
 
+  public PatchSetInserter setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
@@ -367,7 +375,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
-            ImmutableListMultimap.of(),
+            validationOptions,
             ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 57f94ff..a0fa8e9 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -17,8 +17,10 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -94,6 +96,7 @@
   private boolean sendEmail = true;
   private boolean storeCopiedVotes = true;
   private boolean matchAuthorToCommitterDate = false;
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -191,6 +194,13 @@
     return this;
   }
 
+  public RebaseChangeOp setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
       throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
@@ -241,6 +251,8 @@
       patchSetInserter.setWorkInProgress(true);
     }
 
+    patchSetInserter.setValidationOptions(validationOptions);
+
     if (postMessage) {
       patchSetInserter.setMessage(
           messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 0fc5716..cb08c11 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -19,6 +19,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -70,6 +71,7 @@
 import java.time.Instant;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -250,7 +252,6 @@
       @Nullable Boolean workInProgress)
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
-
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
@@ -357,6 +358,7 @@
                   cherryPickCommit,
                   sourceChange,
                   newTopic,
+                  input,
                   workInProgress);
         } else {
           // Change key not found on destination branch. We can create a new
@@ -439,6 +441,7 @@
       CodeReviewCommit cherryPickCommit,
       @Nullable Change sourceChange,
       String topic,
+      CherryPickInput input,
       @Nullable Boolean workInProgress)
       throws IOException {
     Change destChange = destNotes.getChange();
@@ -452,6 +455,7 @@
     if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
       inserter.setWorkInProgress(false);
     }
+    inserter.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -502,6 +506,7 @@
           (sourceChange != null && sourceChange.isWorkInProgress())
               || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
     }
+    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -545,6 +550,20 @@
     return changeId;
   }
 
+  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
   private NotifyResolver.Result resolveNotify(CherryPickInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     return notifyResolver.resolve(
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 1a0f2b6..835fd5a 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -53,6 +55,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -126,6 +129,7 @@
               .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
               .setAllowConflicts(input.allowConflicts)
+              .setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions))
               .setFireRevisionCreated(true);
       // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
       bu.setNotify(NotifyResolver.Result.none());
@@ -246,6 +250,20 @@
     return description;
   }
 
+  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
   public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index c2f9b85..4782729 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -257,7 +257,13 @@
           return "Change " + c.getId() + " is marked work in progress";
         }
         try {
-          MergeOp.checkSubmitRequirements(c);
+          // The data in the change index may be stale (e.g. if submit requirements have been
+          // changed). For that one change for which the submit action is computed, use the
+          // freshly loaded ChangeData instance 'cd' instead of the potentially stale ChangeData
+          // instance 'c' that was loaded from the index. This makes a difference if the ChangeSet
+          // 'cs' only contains this one single change. If the ChangeSet contains further changes
+          // those may still be stale.
+          MergeOp.checkSubmitRequirements(cd.getId().equals(c.getId()) ? cd : c);
         } catch (ResourceConflictException e) {
           return "Change " + c.getId() + " is not ready: " + e.getMessage();
         }
@@ -317,14 +323,6 @@
 
     String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
 
-    // Recheck mergeability rather than using value stored in the index, which may be stale.
-    // TODO(dborowitz): This is ugly; consider providing a way to not read stored fields from the
-    // index in the first place.
-    // cd.setMergeable(null);
-    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
-    // now it is safe to read from the cache, as it yields the same result.
-    Boolean enabled = cd.isMergeable();
-
     if (submitProblems != null) {
       return new UiAction.Description()
           .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
@@ -333,6 +331,14 @@
           .setEnabled(false);
     }
 
+    // Recheck mergeability rather than using value stored in the index, which may be stale.
+    // TODO(dborowitz): This is ugly; consider providing a way to not read stored fields from the
+    // index in the first place.
+    // cd.setMergeable(null);
+    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
+    // now it is safe to read from the cache, as it yields the same result.
+    Boolean enabled = cd.isMergeable();
+
     if (treatWithTopic) {
       Map<String, String> params =
           ImmutableMap.of(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index dbf129a..1a79b53 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -163,7 +163,11 @@
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
+import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -1014,6 +1018,31 @@
   }
 
   @Test
+  public void rebaseWithValidationOptions() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      // Rebase the second change
+      gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void deleteNewChangeAsAdmin() throws Exception {
     deleteChangeAsUser(admin, admin);
   }
@@ -4707,4 +4736,15 @@
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index ff76546..c9a57d0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
@@ -75,6 +76,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.stream.IntStream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -957,6 +959,126 @@
   }
 
   @Test
+  public void submitRequirementThatOverridesParentSubmitRequirementTakesEffectImmediately()
+      throws Exception {
+    // Define submit requirement in root project that ignores self approvals from the uploader.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Apply a self approval from the uploader.
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review+2 is ignored since it's a self approval from the uploader
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Already satisfied since it
+    // doesn't ignore self approvals.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+    // since the change is not submittable we expect the submit action to be not returned
+    assertThat(gApi.changes().id(changeId).current().actions()).doesNotContainKey("submit");
+
+    // Override submit requirement in project (allow uploaders to self approve).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // the self approval from the uploader is no longer ignored, hence the submit requirement is
+    // satisfied now
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    // since the change is submittable now we expect the submit action to be returned
+    Map<String, ActionInfo> actions = gApi.changes().id(changeId).current().actions();
+    assertThat(actions).containsKey("submit");
+    ActionInfo submitAction = actions.get("submit");
+    assertThat(submitAction.enabled).isTrue();
+  }
+
+  @Test
+  public void
+      submitRequirementThatOverridesParentSubmitRequirementTakesEffectImmediately_staleIndex()
+          throws Exception {
+    // Define submit requirement in root project that ignores self approvals from the uploader.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Apply a self approval from the uploader.
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review+2 is ignored since it's a self approval from the uploader
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Already satisfied since it
+    // doesn't ignore self approvals.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+    // since the change is not submittable we expect the submit action to be not returned
+    assertThat(gApi.changes().id(changeId).current().actions()).doesNotContainKey("submit");
+
+    // disable change index writes so that the change in the index gets stale when the new submit
+    // requirement is added
+    disableChangeIndexWrites();
+    try {
+      // Override submit requirement in project (allow uploaders to self approve).
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:Code-Review=MAX"))
+              .setAllowOverrideInChildProjects(true)
+              .build());
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      // the self approval from the uploader is no longer ignored, hence the submit requirement is
+      // satisfied now
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // since the change is submittable now we expect the submit action to be returned
+      Map<String, ActionInfo> actions = gApi.changes().id(changeId).current().actions();
+      assertThat(actions).containsKey("submit");
+      ActionInfo submitAction = actions.get("submit");
+      assertThat(submitAction.enabled).isTrue();
+    } finally {
+      enableChangeIndexWrites();
+    }
+  }
+
+  @Test
   public void submitRequirement_partiallyOverriddenSRIsIgnored() throws Exception {
     // Create build-cop-override label
     LabelDefinitionInput input = new LabelDefinitionInput();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 72b5f93..4a7849f 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -95,6 +95,10 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.FakeEmailSender;
@@ -684,6 +688,26 @@
   }
 
   @Test
+  public void cherryPickWithValidationOptions() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "Cherry Pick";
+    in.validationOptions = ImmutableMap.of("key", "value");
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(r.getChangeId()).current().cherryPickAsInfo(in);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void cherryPickToExistingChangeUpdatesCherryPickOf() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
@@ -2081,4 +2105,15 @@
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
     return Iterables.transform(r, a -> Account.id(a._accountId));
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/plugins/gitiles b/plugins/gitiles
index a0709a4..b62b109 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit a0709a402ee1d4fe3921fd81e575ec48a053cc9f
+Subproject commit b62b1098cfc566f5edb9e9a3fed8be20210675f5
diff --git a/plugins/replication b/plugins/replication
index 98926b4..ba3a9eb 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 98926b44a199b5a7049232f6c3b3758267368f8f
+Subproject commit ba3a9eb92811feef9c2f47287613badee987a9d2
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
index 2a614a7..f97799c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -33,9 +33,12 @@
   getAllUniqueApprovals,
   getRequirements,
   hasNeutralStatus,
+  hasVotes,
   iconForStatus,
 } from '../../../utils/label-util';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined';
+import {capitalizeFirstLetter} from '../../../utils/string-util';
 
 @customElement('gr-change-list-column-requirement')
 export class GrChangeListColumnRequirement extends LitElement {
@@ -67,7 +70,10 @@
   }
 
   override render() {
-    return html`<div class="container ${this.computeClass()}">
+    return html`<div
+      class="container ${this.computeClass()}"
+      title="${ifDefined(this.computeLabelTitle())}"
+    >
       ${this.renderContent()}
     </div>`;
   }
@@ -112,6 +118,7 @@
       return html`<gr-vote-chip
         .vote="${worstVote}"
         .label="${labelInfo}"
+        tooltip-with-who-voted
       ></gr-vote-chip>`;
     }
   }
@@ -133,6 +140,40 @@
     return '';
   }
 
+  private computeLabelTitle() {
+    if (!this.labelName) return;
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0) return 'Requirement not applicable';
+
+    const requirement = requirements[0];
+    if (requirement.status === SubmitRequirementStatus.UNSATISFIED) {
+      const requirementLabels = extractAssociatedLabels(
+        requirement,
+        'onlySubmittability'
+      );
+      const allLabels = this.change?.labels ?? {};
+      const associatedLabels = Object.keys(allLabels).filter(label =>
+        requirementLabels.includes(label)
+      );
+      const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
+      if (requirementWithoutLabelToVoteOn) {
+        const status = capitalizeFirstLetter(requirement.status.toLowerCase());
+        return status;
+      }
+
+      const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
+        label => !hasVotes(allLabels[label])
+      );
+      if (everyAssociatedLabelsIsWithoutVotes) {
+        return 'No votes';
+      } else {
+        return; // there is a vote with tooltip, so undefined label title
+      }
+    } else {
+      return capitalizeFirstLetter(requirement.status.toLowerCase());
+    }
+  }
+
   private getRequirement(labelName: string) {
     const requirements = getRequirements(this.change).filter(
       sr => sr.name === labelName
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
index 698eb0b..11b48be 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
@@ -67,13 +67,16 @@
       >
       </gr-change-list-column-requirement>`
     );
-    expect(element).shadowDom.to.equal(/* HTML */ ` <div class="container">
-      <iron-icon
-        class="check-circle-filled"
-        icon="gr-icons:check-circle-filled"
-      >
-      </iron-icon>
-    </div>`);
+    expect(element).shadowDom.to.equal(
+      /* HTML */
+      ` <div class="container" title="Satisfied">
+        <iron-icon
+          class="check-circle-filled"
+          icon="gr-icons:check-circle-filled"
+        >
+        </iron-icon>
+      </div>`
+    );
   });
 
   test('show worst vote when state is not satisfied', async () => {
@@ -87,8 +90,8 @@
     const label: DetailedLabelInfo = {
       values: VALUES_2,
       all: [
-        {value: -1, _account_id: 777 as AccountId},
-        {value: 1, _account_id: 324 as AccountId},
+        {value: -1, _account_id: 777 as AccountId, name: 'Reviewer'},
+        {value: 1, _account_id: 324 as AccountId, name: 'Reviewer 2'},
       ],
     };
     const submitRequirement: SubmitRequirementResultInfo = {
@@ -114,16 +117,22 @@
       >
       </gr-change-list-column-requirement>`
     );
-    expect(element).shadowDom.to.equal(/* HTML */ ` <div class="container">
-      <gr-vote-chip></gr-vote-chip>
-    </div>`);
+    expect(element).shadowDom.to.equal(
+      /* HTML */
+      ` <div class="container">
+        <gr-vote-chip tooltip-with-who-voted=""></gr-vote-chip>
+      </div>`
+    );
     const voteChip = queryAndAssert(element, 'gr-vote-chip');
-    expect(voteChip).shadowDom.to.equal(/* HTML */ ` <gr-tooltip-content
-      class="container"
-      has-tooltip=""
-      title="bad"
-    >
-      <div class="negative vote-chip">-1</div>
-    </gr-tooltip-content>`);
+    expect(voteChip).shadowDom.to.equal(
+      /* HTML */
+      ` <gr-tooltip-content
+        class="container"
+        has-tooltip=""
+        title="Reviewer: bad"
+      >
+        <div class="negative vote-chip">-1</div>
+      </gr-tooltip-content>`
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index a72749b..604dc51 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -153,10 +153,10 @@
   }
 
   private renderDownloadCommands() {
-    if (!this.schemes.length) return;
+    const cssClass = this.schemes.length ? '' : 'hidden';
 
     return html`
-      <section>
+      <section class=${cssClass}>
         <gr-download-commands
           id="downloadCommands"
           .commands=${this.computeDownloadCommands()}
@@ -216,7 +216,7 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('schemes')) {
+    if (changedProperties.has('change') || changedProperties.has('patchNum')) {
       this.schemesChanged();
     }
   }
@@ -251,7 +251,7 @@
   override focus() {
     if (this.schemes.length) {
       assertIsDefined(this.downloadCommands, 'downloadCommands');
-      this.downloadCommands.focusOnCopy();
+      this.updateComplete.then(() => this.downloadCommands!.focusOnCopy());
     } else {
       assertIsDefined(this.download, 'download');
       this.download.focus();
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 8216f7b..ceb0ba4 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -42,7 +42,7 @@
   orderSubmitRequirements,
 } from '../../../utils/label-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {charsOnly} from '../../../utils/string-util';
+import {capitalizeFirstLetter, charsOnly} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {CheckRun} from '../../../models/checks/checks-model';
 import {getResultsOf, hasResultsOf} from '../../../models/checks/checks-util';
@@ -264,6 +264,12 @@
 
     const checksChips = this.renderChecks(requirement);
 
+    const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
+    if (requirementWithoutLabelToVoteOn) {
+      const status = capitalizeFirstLetter(requirement.status.toLowerCase());
+      return checksChips || html`${status}`;
+    }
+
     if (everyAssociatedLabelsIsWithoutVotes) {
       return checksChips || html`No votes`;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index 3e02afb..f6dc75c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -34,6 +34,7 @@
 
 suite('gr-submit-requirements tests', () => {
   let element: GrSubmitRequirements;
+  let change: ParsedChangeInfo;
   setup(async () => {
     const submitRequirement: SubmitRequirementResultInfo = {
       ...createSubmitRequirementResultInfo(),
@@ -43,7 +44,7 @@
         expression: 'label:Verified=MAX -label:Verified=MIN',
       },
     };
-    const change: ParsedChangeInfo = {
+    change = {
       ...createParsedChange(),
       submit_requirements: [
         submitRequirement,
@@ -115,6 +116,48 @@
     `);
   });
 
+  suite('votes-cell', () => {
+    setup(async () => {
+      element.disableEndpoints = true;
+      await element.updateComplete;
+    });
+    test('with vote', () => {
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">
+          <gr-vote-chip> </gr-vote-chip>
+        </div>
+      `);
+    });
+
+    test('no votes', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Verified: {
+          ...createDetailedLabelInfo(),
+        },
+      };
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">No votes</div>
+      `);
+    });
+
+    test('without label to vote on', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.submit_requirements![0]!.submittability_expression_result!.expression =
+        'hasfooter:"Release-Notes"';
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">Satisfied</div>
+      `);
+    });
+  });
+
   test('calculateEndpointName()', () => {
     assert.equal(
       element.calculateEndpointName('code-owners~CodeOwnerSub'),
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index b41147b..477f579 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -429,6 +429,7 @@
         .account="${account}"
         @click="${this.handleAccountClicked}"
         selectionChipStyle
+        noStatusIcons
         ?selected="${selected}"
       ></gr-account-label>
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
index 5b85092..33a4e21 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -321,22 +321,27 @@
         <gr-account-label
           deselected=""
           selectionchipstyle=""
+          nostatusicons=""
         ></gr-account-label>
         <gr-account-label
           deselected=""
           selectionchipstyle=""
+          nostatusicons=""
         ></gr-account-label>
         <gr-account-label
           deselected=""
           selectionchipstyle=""
+          nostatusicons=""
         ></gr-account-label>
         <gr-account-label
           deselected=""
           selectionchipstyle=""
+          nostatusicons=""
         ></gr-account-label>
         <gr-account-label
           deselected=""
           selectionchipstyle=""
+          nostatusicons=""
         ></gr-account-label>
       </div>
       <div id="threads" part="threads">
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
index 51259c8..a6ea1f6 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -111,7 +111,7 @@
       </span>
     </section>
     <section>
-      <label class="title" for="statusInput">Status (e.g. "Vacation")</label>
+      <label class="title" for="statusInput">About me (e.g. employer)</label>
       <span class="value">
         <iron-input
           on-keydown="_handleKeydown"
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index d17c06b..a12d289 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -60,9 +60,9 @@
     account = createAccountWithIdNameAndEmail(123) as AccountDetailInfo;
     config = createServerInfo();
 
-    stubRestApi('getAccount').returns(Promise.resolve(account));
-    stubRestApi('getConfig').returns(Promise.resolve(config));
-    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
+    stubRestApi('getAccount').resolves(account);
+    stubRestApi('getConfig').resolves(config);
+    stubRestApi('getPreferences').resolves(createPreferences());
 
     element = basicFixture.instantiate();
     await element.loadData();
@@ -124,7 +124,7 @@
         </section>
         <section>
           <label class="title" for="statusInput">
-            Status (e.g. "Vacation")
+            About me (e.g. employer)
           </label>
           <span class="value">
             <iron-input>
@@ -217,11 +217,9 @@
         auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']},
       });
 
-      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
-      usernameStub = stubRestApi('setAccountUsername').returns(
-        Promise.resolve()
-      );
-      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
+      nameStub = stubRestApi('setAccountName').resolves();
+      usernameStub = stubRestApi('setAccountUsername').resolves();
+      statusStub = stubRestApi('setAccountStatus').resolves();
     });
 
     test('name', async () => {
@@ -292,9 +290,9 @@
         auth: {editable_account_fields: ['FULL_NAME']},
       });
 
-      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
-      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
-      stubRestApi('setAccountUsername').returns(Promise.resolve());
+      nameStub = stubRestApi('setAccountName').resolves();
+      statusStub = stubRestApi('setAccountStatus').resolves();
+      stubRestApi('setAccountUsername').resolves();
     });
 
     test('set name and status', async () => {
@@ -329,7 +327,7 @@
       statusChangedSpy = sinon.spy(element, '_statusChanged');
       element.set('_serverConfig', {auth: {editable_account_fields: []}});
 
-      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
+      statusStub = stubRestApi('setAccountStatus').resolves();
     });
 
     test('read full name but set status', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 274601f..43f49d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -91,6 +91,9 @@
   @property({type: Boolean, reflect: true})
   selectionChipStyle = false;
 
+  @property({type: Boolean, reflect: true})
+  noStatusIcons = false;
+
   @property({
     type: Boolean,
     reflect: true,
@@ -268,7 +271,7 @@
   }
 
   private renderAccountStatusPlugins() {
-    if (!this.account?._account_id) {
+    if (!this.account?._account_id || this.noStatusIcons) {
       return;
     }
     return html`
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index 820cb0e..654fb33 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma';
 import './gr-account-label';
 import {
+  query,
   queryAndAssert,
   spyRestApi,
   stubRestApi,
@@ -151,5 +152,19 @@
       assert.isTrue(apiSpy.calledOnce);
       assert.equal(apiSpy.lastCall.args[1], 42);
     });
+
+    test('no status icons attribute', async () => {
+      queryAndAssert(
+        element,
+        'gr-endpoint-decorator[name="account-status-icon"]'
+      );
+
+      element.noStatusIcons = true;
+      await element.updateComplete;
+
+      assert.notExists(
+        query(element, 'gr-endpoint-decorator[name="account-status-icon"]')
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 210041a..01a72ae 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -159,10 +159,8 @@
   }
 
   private renderCommands() {
-    if (!this.schemes.length) return;
-
     return html`
-      <div class="commands">
+      <div class="commands" ?hidden="${!this.schemes.length}"></div>
         ${this.commands?.map((command, index) =>
           this.renderShellCommand(command, index)
         )}
@@ -181,8 +179,12 @@
     `;
   }
 
-  focusOnCopy() {
-    queryAndAssert<GrShellCommand>(this, 'gr-shell-command').focusOnCopy();
+  async focusOnCopy() {
+    await this.updateComplete;
+    await queryAndAssert<GrShellCommand>(
+      this,
+      'gr-shell-command'
+    ).focusOnCopy();
   }
 
   private handleTabChange = (e: CustomEvent<{value: number}>) => {
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 0e8cf82..9efecfd 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -72,12 +72,12 @@
       await element.updateComplete;
     });
 
-    test('focusOnCopy', () => {
+    test('focusOnCopy', async () => {
       const focusStub = sinon.stub(
         queryAndAssert<GrShellCommand>(element, 'gr-shell-command'),
         'focusOnCopy'
       );
-      element.focusOnCopy();
+      await element.focusOnCopy();
       assert.isTrue(focusStub.called);
     });
 
@@ -89,7 +89,8 @@
       element.schemes = [];
       await element.updateComplete;
       assert.isTrue(isHidden(queryAndAssert(element, 'paper-tabs')));
-      assert.isFalse(Boolean(query(element, '.commands')));
+      assert.isTrue(Boolean(query(element, '.commands')));
+      assert.isTrue(isHidden(queryAndAssert(element, '.commands')));
       // Should still be present but hidden
       assert.isTrue(Boolean(query(element, '#downloadTabs')));
       assert.isTrue(isHidden(queryAndAssert(element, '#downloadTabs')));
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 4330451..b638bc1 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -235,7 +235,7 @@
     if (!this.account.status) return;
     return html`
       <div class="status">
-        <span class="title">Status:</span>
+        <span class="title">About me:</span>
         <span class="value">${this.account.status}</span>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
index 19e6eae..1d67500 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
@@ -46,6 +46,7 @@
     email: 'kermit@gmail.com' as EmailAddress,
     username: 'kermit',
     name: 'Kermit The Frog',
+    status: 'I am a frog',
     _account_id: 31415926535 as AccountId,
   };
 
@@ -91,6 +92,10 @@
             <gr-endpoint-param name="account"></gr-endpoint-param>
           </gr-endpoint-decorator>
         </div>
+        <div class="status">
+          <span class="title">About me:</span>
+          <span class="value">I am a frog</span>
+        </div>
       </div>
     `);
   });
@@ -110,15 +115,15 @@
     assert.equal(element.computePronoun(), 'Their');
   });
 
-  test('account status is not shown if the property is not set', () => {
+  test('account status is not shown if the property is not set', async () => {
+    element.account = {...ACCOUNT, status: undefined};
+    await element.updateComplete;
     assert.isUndefined(query(element, '.status'));
   });
 
-  test('account status is displayed', async () => {
-    element.account = {...ACCOUNT, status: 'OOO'};
-    await element.updateComplete;
+  test('account status is displayed', () => {
     const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
-    assert.equal(status.innerText, 'OOO');
+    assert.equal(status.innerText, 'I am a frog');
   });
 
   test('voteable div is not shown if the property is not set', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
index 6d0839f..4b0560d 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
@@ -157,7 +157,6 @@
 import {ErrorCallback} from '../../../api/rest';
 import {addDraftProp, DraftInfo} from '../../../utils/comment-util';
 import {BaseScheduler} from '../../../services/scheduler/scheduler';
-import {RetryScheduler} from '../../../services/scheduler/retry-scheduler';
 import {MaxInFlightScheduler} from '../../../services/scheduler/max-in-flight-scheduler';
 
 const MAX_PROJECT_RESULTS = 25;
@@ -283,19 +282,11 @@
 }
 
 function createReadScheduler() {
-  return new RetryScheduler<Response>(
-    new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10),
-    3,
-    50
-  );
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10);
 }
 
 function createWriteScheduler() {
-  return new RetryScheduler<Response>(
-    new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5),
-    3,
-    50
-  );
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
 }
 
 @customElement('gr-rest-api-service-impl')
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index 7ed000b..6e1b20c 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -88,7 +88,8 @@
       </div>`;
   }
 
-  focusOnCopy() {
+  async focusOnCopy() {
+    await this.updateComplete;
     const copyClipboard = queryAndAssert<GrCopyClipboard>(
       this,
       'gr-copy-clipboard'
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index a50b60b..1b1687b 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -33,12 +33,12 @@
     await flush();
   });
 
-  test('focusOnCopy', () => {
+  test('focusOnCopy', async () => {
     const focusStub = sinon.stub(
       queryAndAssert<GrCopyClipboard>(element, 'gr-copy-clipboard')!,
       'focusOnCopy'
     );
-    element.focusOnCopy();
+    await element.focusOnCopy();
     assert.isTrue(focusStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
index 487145f..d89ed65 100644
--- a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
@@ -58,6 +58,9 @@
   @property()
   displayValue?: string;
 
+  @property({type: Boolean, attribute: 'tooltip-with-who-voted'})
+  tooltipWithWhoVoted = false;
+
   private readonly flagsService = getAppContext().flagsService;
 
   static override get styles() {
@@ -186,6 +189,13 @@
     if (!this.label || !isDetailedLabelInfo(this.label)) {
       return '';
     }
-    return this.label.values?.[valueString(this.vote?.value)] ?? '';
+    const voteDescription =
+      this.label.values?.[valueString(this.vote?.value)] ?? '';
+
+    if (this.tooltipWithWhoVoted && this.vote) {
+      return `${this.vote?.name}: ${voteDescription}`;
+    } else {
+      return voteDescription;
+    }
   }
 }
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 0b217ec..0a0928e 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -47,3 +47,7 @@
 export function convertToString(key?: unknown) {
   return key !== undefined ? String(key) : '';
 }
+
+export function capitalizeFirstLetter(str: string) {
+  return str.charAt(0).toUpperCase() + str.slice(1);
+}
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 62049e7..5c8f165 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -14,7 +14,7 @@
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
 PROLOG_REPO = GERRIT
-GITILES_VERS = "0.4-1"
+GITILES_VERS = "1.0.0"
 GITILES_REPO = GERRIT
 
 # When updating Bouncy Castle, also update it in bazlets.
@@ -544,14 +544,14 @@
         artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
         attach_source = False,
         repository = GITILES_REPO,
-        sha1 = "0df80c6b8822147e1f116fd7804b8a0de544f402",
+        sha1 = "f46833f8aa6f33ce3e443c8a414c295559eaf43e",
     )
 
     maven_jar(
         name = "gitiles-servlet",
         artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
         repository = GITILES_REPO,
-        sha1 = "60870897d22b840e65623fd024eabd9cc9706ebe",
+        sha1 = "90e107da00c2cd32490dd9ae8e3fb1ee095ea675",
     )
 
     # prettify must match the version used in Gitiles