Merge "Submit Requirements - Refine gr-account chip"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index a1dc27c..ace6995 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2216,6 +2216,9 @@
 other project managed by the running server. The name is
 relative to `gerrit.basePath`.
 +
+The link:#cache_names[persisted_projects cache] must be
+flushed after this setting is changed.
++
 Defaults to `All-Projects` if not set.
 
 [[gerrit.defaultBranch]]gerrit.defaultBranch::
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 1f67fc7..512f784 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -2,8 +2,6 @@
 
 Report a bug or send feedback using
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
-You can also report a bug through the bug icon in the user hovercard and in the
-reply dialog.
 
 [[whose-turn]]
 == Whose turn is it?
@@ -133,24 +131,6 @@
 Gerrit-Attention: Marian Harbach <mharbach@google.com>
 ----
 
-=== Assignee
-
-While the "Assignee" feature can still be used together with the attention set,
-we do not recommend doing so. Using both features is likely confusing. The
-distinct feature of the "Assignee" compared to the attention set is that only
-one user can be the assignee at the same time. So the assignee can be used to
-single out one person or escalate, if there are multiple reviewers. Since
-*every* reviewer in the attention set is expected to take action, singling out
-is not likely to be important and also still achievable with the attention set.
-Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
-recommend to only use one of them.
-
-If you don't expect action from reviewers, then consider adding them to CC
-instead.
-
-The "Assignee" feature can be turned on/off with the
-link:config-gerrit.html#change.enableAssignee[enableAssignee] config option.
-
 === Bold Changes / Mark Reviewed
 
 Before the attention set feature, changes were bolded in the dashboard when
diff --git a/java/com/google/gerrit/entities/AccessSection.java b/java/com/google/gerrit/entities/AccessSection.java
index 69a234a..8ae0a5d 100644
--- a/java/com/google/gerrit/entities/AccessSection.java
+++ b/java/com/google/gerrit/entities/AccessSection.java
@@ -18,12 +18,14 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Consumer;
+import java.util.regex.Pattern;
 
 /** Portion of a {@link Project} describing access rules. */
 @AutoValue
@@ -42,6 +44,20 @@
   /** Name of the access section. It could be a ref pattern or something else. */
   public abstract String getName();
 
+  /**
+   * A compiled regular expression in case {@link #getName()} is a regular expression. This is
+   * memoized to save callers from compiling patterns for every use.
+   */
+  @Memoized
+  public Optional<Pattern> getNamePattern() {
+    if (isValidRefSectionName(getName())
+        && getName().startsWith(REGEX_PREFIX)
+        && !getName().contains("${")) {
+      return Optional.of(Pattern.compile(getName()));
+    }
+    return Optional.empty();
+  }
+
   public abstract ImmutableList<Permission> getPermissions();
 
   public static AccessSection create(String name) {
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index cd65efc..be4a1cf 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
@@ -69,7 +70,7 @@
   public abstract AccountsSection getAccountsSection();
 
   /** Returns a map of {@link AccessSection}s keyed by their name. */
-  public abstract ImmutableMap<String, AccessSection> getAccessSections();
+  public abstract ImmutableSortedMap<String, AccessSection> getAccessSections();
 
   /** Returns the {@link AccessSection} with to the given name. */
   public Optional<AccessSection> getAccessSection(String refName) {
@@ -235,7 +236,7 @@
 
     protected abstract ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupsBuilder();
 
-    protected abstract ImmutableMap.Builder<String, AccessSection> accessSectionsBuilder();
+    protected abstract ImmutableSortedMap.Builder<String, AccessSection> accessSectionsBuilder();
 
     protected abstract ImmutableMap.Builder<String, ContributorAgreement>
         contributorAgreementsBuilder();
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 27c6793..1191db8 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -173,7 +173,13 @@
       return ref(notes.getChange().getDest()).change(notes);
     }
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(GlobalOrPluginPermission)}.
+     */
     public abstract void check(GlobalOrPluginPermission perm)
         throws AuthException, PermissionBackendException;
 
@@ -280,7 +286,13 @@
       return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(CoreOrPluginProjectPermission)}.
+     */
     public abstract void check(CoreOrPluginProjectPermission perm)
         throws AuthException, PermissionBackendException;
 
@@ -368,7 +380,13 @@
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeNotes notes);
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(RefPermission)}.
+     */
     public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
 
     /** Filter {@code permSet} to permissions scoped user might be able to perform. */
@@ -406,7 +424,13 @@
     /** Returns the fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(ChangePermissionOrLabel)}.
+     */
     public abstract void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException;
 
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 31bbff5..3aa3783 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -130,7 +130,7 @@
             .keySerializer(new ProtobufSerializer<>(Cache.ProjectCacheKeyProto.parser()))
             .valueSerializer(PersistedProjectConfigSerializer.INSTANCE)
             .diskLimit(1 << 30) // 1 GiB
-            .version(3)
+            .version(4)
             .maximumWeight(0);
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 4a17b5c..123a14c 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -224,7 +224,8 @@
           projectName,
           projectName.equals(allProjectsName)
               ? allProjectsConfigProvider.get(allProjectsName)
-              : Optional.empty());
+              : Optional.empty(),
+          allProjectsName);
     }
 
     public ProjectConfig read(MetaDataUpdate update) throws IOException, ConfigInvalidException {
@@ -250,6 +251,7 @@
   }
 
   private final Optional<StoredConfig> baseConfig;
+  private final AllProjectsName allProjectsName;
 
   private Project project;
   private AccountsSection accountsSection;
@@ -287,7 +289,6 @@
             .setCheckReceivedObjects(checkReceivedObjects)
             .setExtensionPanelSections(extensionPanelSections);
     groupList.byUUID().values().forEach(g -> builder.addGroup(g));
-    accessSections.values().forEach(a -> builder.addAccessSection(a));
     contributorAgreements.values().forEach(c -> builder.addContributorAgreement(c));
     notifySections.values().forEach(n -> builder.addNotifySection(n));
     subscribeSections.values().forEach(s -> builder.addSubscribeSection(s));
@@ -300,6 +301,28 @@
     projectLevelConfigs
         .entrySet()
         .forEach(c -> builder.addProjectLevelConfig(c.getKey(), c.getValue().toText()));
+
+    if (projectName.equals(allProjectsName)) {
+      // Filter out permissions that aren't allowed to be set on All-Projects
+      accessSections
+          .values()
+          .forEach(
+              a -> {
+                List<Permission.Builder> copy = new ArrayList<>();
+                for (Permission p : a.getPermissions()) {
+                  if (Permission.canBeOnAllProjects(a.getName(), p.getName())) {
+                    copy.add(p.toBuilder());
+                  }
+                }
+                AccessSection section =
+                    AccessSection.builder(a.getName())
+                        .modifyPermissions(permissions -> permissions.addAll(copy))
+                        .build();
+                builder.addAccessSection(section);
+              });
+    } else {
+      accessSections.values().forEach(a -> builder.addAccessSection(a));
+    }
     return builder.build();
   }
 
@@ -355,9 +378,13 @@
     requireNonNull(commentLinkSections.remove(name));
   }
 
-  private ProjectConfig(Project.NameKey projectName, Optional<StoredConfig> baseConfig) {
+  private ProjectConfig(
+      Project.NameKey projectName,
+      Optional<StoredConfig> baseConfig,
+      AllProjectsName allProjectsName) {
     this.projectName = projectName;
     this.baseConfig = baseConfig;
+    this.allProjectsName = allProjectsName;
   }
 
   public void load(Repository repo) throws IOException, ConfigInvalidException {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 69e6036..6352f66 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -15,9 +15,7 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
-import static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
@@ -268,35 +266,18 @@
 
   /** Get the sections that pertain only to this project. */
   List<SectionMatcher> getLocalAccessSections() {
-    List<SectionMatcher> sm = localAccessSections;
-    if (sm == null) {
-      ImmutableList<AccessSection> fromConfig =
-          cachedConfig.getAccessSections().values().stream()
-              .sorted(comparing(AccessSection::getName))
-              .collect(toImmutableList());
-      sm = new ArrayList<>(fromConfig.size());
-      for (AccessSection section : fromConfig) {
-        if (isAllProjects) {
-          List<Permission.Builder> copy = new ArrayList<>();
-          for (Permission p : section.getPermissions()) {
-            if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
-              copy.add(p.toBuilder());
-            }
-          }
-          section =
-              AccessSection.builder(section.getName())
-                  .modifyPermissions(permissions -> permissions.addAll(copy))
-                  .build();
-        }
-
-        SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
-        if (matcher != null) {
-          sm.add(matcher);
-        }
-      }
-      localAccessSections = sm;
+    if (localAccessSections != null) {
+      return localAccessSections;
     }
-    return sm;
+    List<SectionMatcher> sm = new ArrayList<>(cachedConfig.getAccessSections().values().size());
+    for (AccessSection section : cachedConfig.getAccessSections().values()) {
+      SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
+      if (matcher != null) {
+        sm.add(matcher);
+      }
+    }
+    localAccessSections = sm;
+    return localAccessSections;
   }
 
   /**
diff --git a/java/com/google/gerrit/server/project/RefPatternMatcher.java b/java/com/google/gerrit/server/project/RefPatternMatcher.java
index b9076b3..be840b5 100644
--- a/java/com/google/gerrit/server/project/RefPatternMatcher.java
+++ b/java/com/google/gerrit/server/project/RefPatternMatcher.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.CurrentUser;
@@ -32,6 +33,13 @@
 import java.util.stream.Stream;
 
 public abstract class RefPatternMatcher {
+  public static RefPatternMatcher getMatcher(AccessSection section) {
+    if (section.getNamePattern().isPresent()) {
+      return new Regexp(section.getNamePattern().get());
+    }
+    return getMatcher(section.getName());
+  }
+
   public static RefPatternMatcher getMatcher(String pattern) {
     if (containsParameters(pattern)) {
       return new ExpandParameters(pattern);
@@ -79,6 +87,10 @@
       pattern = Pattern.compile(re);
     }
 
+    Regexp(Pattern re) {
+      pattern = re;
+    }
+
     @Override
     public boolean match(String ref, CurrentUser user) {
       return pattern.matcher(ref).matches() || (isRE(ref) && pattern.pattern().equals(ref));
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 763957e..3d7175f 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -28,7 +28,7 @@
   static SectionMatcher wrap(Project.NameKey project, AccessSection section) {
     String ref = section.getName();
     if (AccessSection.isValidRefSectionName(ref)) {
-      return new SectionMatcher(project, section, getMatcher(ref));
+      return new SectionMatcher(project, section, getMatcher(section));
     }
     return null;
   }
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 5cbae90..9eb8aa4 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -558,7 +558,7 @@
 export declare interface ContributorAgreementInfo {
   name: string;
   description: string;
-  url: string;
+  url?: string;
   auto_verify_group?: GroupInfo;
 }
 
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
new file mode 100644
index 0000000..c832f13
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
+import '../../shared/gr-change-status/gr-change-status';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
+import {getRequirements, iconForStatus} from '../../../utils/label-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+
+@customElement('gr-change-list-column-requirement')
+export class GrChangeListColumnRequirement extends LitElement {
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property()
+  labelName?: string;
+
+  static override get styles() {
+    return [
+      submitRequirementsStyles,
+      sharedStyles,
+      css`
+        iron-icon {
+          vertical-align: top;
+        }
+        .container.not-applicable {
+          background-color: var(--table-header-background-color);
+          height: calc(var(--line-height-normal) + var(--spacing-m));
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div class="container ${this.computeClass()}">
+      ${this.renderContent()}
+    </div>`;
+  }
+
+  private renderContent() {
+    if (!this.labelName) return;
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0) return;
+
+    const icon = iconForStatus(
+      requirements[0].status ?? SubmitRequirementStatus.ERROR
+    );
+    return html`<iron-icon
+      class="${icon}"
+      icon="gr-icons:${icon}"
+    ></iron-icon>`;
+  }
+
+  private computeClass(): string {
+    if (!this.labelName) return '';
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0) {
+      return 'not-applicable';
+    }
+    return '';
+  }
+
+  private getRequirement(labelName: string) {
+    const requirements = getRequirements(this.change).filter(
+      sr => sr.name === labelName
+    );
+    // TODO(milutin): Remove this after migration from legacy requirements.
+    if (requirements.length > 1) {
+      return requirements.filter(sr => !sr.is_legacy);
+    } else {
+      return requirements;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-column-requirement': GrChangeListColumnRequirement;
+  }
+}
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
new file mode 100644
index 0000000..01fcd2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-change-list-column-requirement';
+import {GrChangeListColumnRequirement} from './gr-change-list-column-requirement';
+import {
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+  createChange,
+} from '../../../test/test-data-generators';
+import {ChangeInfo, SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {StandardLabels} from '../../../utils/label-util';
+
+suite('gr-change-list-column-requirement tests', () => {
+  let element: GrChangeListColumnRequirement;
+  let change: ChangeInfo;
+  setup(() => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      name: StandardLabels.CODE_REVIEW,
+      submittability_expression_result: {
+        ...createSubmitRequirementExpressionInfo(),
+        expression: 'label:Verified=MAX -label:Verified=MIN',
+      },
+    };
+    change = {
+      ...createChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      unresolved_comment_count: 1,
+    };
+  });
+
+  test('renders', async () => {
+    element = await fixture<GrChangeListColumnRequirement>(
+      html`<gr-change-list-column-requirement
+        .change=${change}
+        .labelName=${StandardLabels.CODE_REVIEW}
+      >
+      </gr-change-list-column-requirement>`
+    );
+    expect(element).shadowDom.to.equal(`<div class="container">
+      <iron-icon
+        class="check-circle-filled"
+        icon="gr-icons:check-circle-filled"
+      >
+      </iron-icon>
+    </div>`);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index a09466b..73b043a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -25,6 +25,7 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary';
+import '../gr-change-list-column-requirement/gr-change-list-column-requirement';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getDisplayName} from '../../../utils/display-name-util';
@@ -44,17 +45,14 @@
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {pluralize} from '../../../utils/string-util';
-import {
-  getRequirements,
-  iconForStatus,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {showNewSubmitRequirements} from '../../../utils/label-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 import {ifDefined} from 'lit/directives/if-defined';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 enum ChangeSize {
   XS = 10,
@@ -250,12 +248,20 @@
         .placeholder {
           color: var(--deemphasized-text-color);
         }
+        .cell.selection input {
+          vertical-align: middle;
+        }
         .cell.label {
           font-weight: var(--font-weight-normal);
         }
         .cell.label iron-icon {
           vertical-align: top;
         }
+        /* Requirement child needs whole area */
+        .cell.requirement {
+          padding: 0;
+          margin: 0;
+        }
         @media only screen and (max-width: 50em) {
           :host {
             display: flex;
@@ -269,13 +275,14 @@
     const changeUrl = this.computeChangeURL();
     return html`
       <td aria-hidden="true" class="cell leftPadding"></td>
-      ${this.renderCellStar()} ${this.renderCellNumber(changeUrl)}
-      ${this.renderCellSubject(changeUrl)} ${this.renderCellStatus()}
-      ${this.renderCellOwner()} ${this.renderCellReviewers()}
-      ${this.renderCellComments()} ${this.renderCellRepo()}
-      ${this.renderCellBranch()} ${this.renderCellUpdated()}
-      ${this.renderCellSubmitted()} ${this.renderCellWaiting()}
-      ${this.renderCellSize()} ${this.renderCellRequirements()}
+      ${this.renderCellSelectionBox()} ${this.renderCellStar()}
+      ${this.renderCellNumber(changeUrl)} ${this.renderCellSubject(changeUrl)}
+      ${this.renderCellStatus()} ${this.renderCellOwner()}
+      ${this.renderCellReviewers()} ${this.renderCellComments()}
+      ${this.renderCellRepo()} ${this.renderCellBranch()}
+      ${this.renderCellUpdated()} ${this.renderCellSubmitted()}
+      ${this.renderCellWaiting()} ${this.renderCellSize()}
+      ${this.renderCellRequirements()}
       ${this.labelNames?.map(labelNames => this.renderChangeLabels(labelNames))}
       ${this.dynamicCellEndpoints?.map(pluginEndpointName =>
         this.renderChangePluginEndpoint(pluginEndpointName)
@@ -283,6 +290,15 @@
     `;
   }
 
+  private renderCellSelectionBox() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
+    return html`
+      <td class="cell selection">
+        <input type="checkbox" />
+      </td>
+    `;
+  }
+
   private renderCellStar() {
     if (!this.showStar) return;
 
@@ -535,6 +551,15 @@
   }
 
   private renderChangeLabels(labelName: string) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
+      return html` <td class="cell label requirement">
+        <gr-change-list-column-requirement
+          .change=${this.change}
+          .labelName=${labelName}
+        >
+        </gr-change-list-column-requirement>
+      </td>`;
+    }
     return html`
       <td
         title="${this.computeLabelTitle(labelName)}"
@@ -546,16 +571,6 @@
   }
 
   private renderChangeHasLabelIcon(labelName: string) {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      const requirements = this.getRequirement(labelName);
-      if (requirements.length === 1) {
-        const icon = iconForStatus(requirements[0].status);
-        return html`<iron-icon
-          class="${icon}"
-          icon="gr-icons:${icon}"
-        ></iron-icon>`;
-      }
-    }
     if (this.computeLabelIcon(labelName) === '')
       return html`<span>${this.computeLabelValue(labelName)}</span>`;
 
@@ -611,14 +626,6 @@
   // private but used in test
   computeLabelClass(labelName: string) {
     const classes = ['cell', 'label'];
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      const requirements = this.getRequirement(labelName);
-      if (requirements.length === 1) {
-        classes.push('requirement');
-        // Do not add label category classes.
-        return classes.sort().join(' ');
-      }
-    }
     const category = this.computeLabelCategory(labelName);
     switch (category) {
       case LabelCategory.NOT_APPLICABLE:
@@ -900,16 +907,4 @@
     const isLast = index === primaryCount - 1;
     return isLast && additionalCount === 0;
   }
-
-  private getRequirement(labelName: string) {
-    const requirements = getRequirements(this.change).filter(
-      sr => sr.name === labelName
-    );
-    // TODO(milutin): Remove this after migration from legacy requirements.
-    if (requirements.length > 1) {
-      return requirements.filter(sr => !sr.is_legacy);
-    } else {
-      return requirements;
-    }
-  }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index ab8d2d7..cd43151 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -27,7 +27,12 @@
   createSubmitRequirementResultInfo,
   createNonApplicableSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
-import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  query,
+  queryAndAssert,
+  stubRestApi,
+  stubFlags,
+} from '../../../test/test-utils';
 import {
   AccountId,
   BranchName,
@@ -288,6 +293,14 @@
     }
   });
 
+  test('selection checkbox is only shown if experiment is enabled', async () => {
+    assert.isNotOk(query(element, '.selection'));
+    stubFlags('isEnabled').returns(true);
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+    assert.isOk(query(element, '.selection'));
+  });
+
   test('repo column hidden', async () => {
     element.visibleChangeTableColumns = [
       'Subject',
@@ -498,9 +511,7 @@
     );
 
     const requirement = queryAndAssert(element, '.requirement');
-    expect(requirement).dom.to.equal(`<iron-icon
-        class="check-circle-filled" 
-        icon="gr-icons:check-circle-filled">
-      </iron-icon>`);
+    expect(requirement).dom.to.equal(`<gr-change-list-column-requirement>
+    </gr-change-list-column-requirement>`);
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index f276260..f8b1a2b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -252,6 +252,11 @@
     `;
   }
 
+  private renderSelectionHeader() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
+    return html`<td aria-hidden="true" class="selection"></td>`;
+  }
+
   private renderSectionHeader(
     changeSection: ChangeListSection,
     labelNames: string[]
@@ -262,6 +267,7 @@
       <tbody>
         <tr class="groupHeader">
           <td aria-hidden="true" class="leftPadding"></td>
+          ${this.renderSelectionHeader()}
           <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
           <td
             class="cell"
@@ -317,6 +323,7 @@
     return html`
       <tr class="groupTitle">
         <td class="leftPadding" aria-hidden="true"></td>
+        ${this.renderSelectionHeader()}
         <td
           class="star"
           aria-label="Star status column"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 50708c0..29204b6 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -292,6 +292,41 @@
     });
   });
 
+  test('selection checkbox is only shown if experiment is enabled', async () => {
+    function propertiesSetup(element: GrChangeList) {
+      element.sections = [{results: [{...createChange()}]}];
+      element.account = {_account_id: 1001 as AccountId};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Reviewers',
+          'Comments',
+          'Repo',
+          'Branch',
+          'Updated',
+          'Size',
+          ' Status ',
+        ],
+      };
+      element.config = createServerInfo();
+    }
+
+    element = basicFixture.instantiate();
+    propertiesSetup(element);
+    await element.updateComplete;
+    assert.isNotOk(query(element, '.selection'));
+
+    stubFlags('isEnabled').returns(true);
+    element = basicFixture.instantiate();
+    propertiesSetup(element);
+    await element.updateComplete;
+    assert.isOk(query(element, '.selection'));
+  });
+
   suite('empty column preference', () => {
     let element: GrChangeList;
 
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 9492fa9..d01ae02 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -14,14 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../gr-trigger-vote/gr-trigger-vote';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
+import {ChangeInfo} from '../../../api/rest-api';
 import {
   ChangeMessage,
   LabelExtreme,
   PATCH_SET_PREFIX_PATTERN,
 } from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {getTriggerVotes} from '../../../utils/label-util';
 
 const VOTE_RESET_TEXT = '0 (vote reset)';
 
@@ -41,16 +44,22 @@
   @property({type: Object})
   message?: ChangeMessage;
 
+  @property({type: Object})
+  change?: ChangeInfo;
+
   static override get styles() {
     return css`
+      .score,
+      gr-trigger-vote {
+        padding: 0 var(--spacing-s);
+        margin-right: var(--spacing-s);
+        display: inline-block;
+      }
       .score {
         box-sizing: border-box;
         border-radius: var(--border-radius);
         color: var(--vote-text-color);
-        display: inline-block;
-        padding: 0 var(--spacing-s);
         text-align: center;
-        margin-right: var(--spacing-s);
         min-width: 115px;
       }
       .score.removed {
@@ -93,10 +102,22 @@
 
   override render() {
     const scores = this._getScores(this.message, this.labelExtremes);
-    return scores.map(score => this.renderScore(score));
+    const triggerVotes = getTriggerVotes(this.change);
+    return scores.map(score => this.renderScore(score, triggerVotes));
   }
 
-  private renderScore(score: Score) {
+  private renderScore(score: Score, triggerVotes: string[]) {
+    if (score.label && triggerVotes.includes(score.label)) {
+      const labels = this.change?.labels ?? {};
+      return html`<gr-trigger-vote
+        .label="${score.label}"
+        .labelInfo="${labels[score.label]}"
+        .change="${this.change}"
+        .mutable="${false}"
+        disable-hovercards
+      >
+      </gr-trigger-vote>`;
+    }
     return html`<span
       class="score ${this._computeScoreClass(score, this.labelExtremes)}"
     >
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 628af83..70e6381 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -190,6 +190,7 @@
         <gr-message-scores
           label-extremes="[[labelExtremes]]"
           message="[[message]]"
+          change="[[change]]"
         ></gr-message-scores>
       </div>
       <template is="dom-if" if="[[_commentCountText]]">
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 8fa2fa4..c5e8b8c 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
@@ -356,6 +356,7 @@
               .change="${this.change}"
               .account="${this.account}"
               .mutable="${this.mutable ?? false}"
+              .disableHovercards=${this.disableHovercards}
             ></gr-trigger-vote>`
         )}
       </section>`;
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
index d920b46..a00de22 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
@@ -48,6 +48,9 @@
   @property({type: Boolean})
   mutable?: boolean;
 
+  @property({type: Boolean, attribute: 'disable-hovercards'})
+  disableHovercards = false;
+
   static override get styles() {
     return css`
       :host {
@@ -84,26 +87,31 @@
     if (!this.labelInfo) return;
     return html`
       <div class="container">
-        <gr-trigger-vote-hovercard
-          .labelName=${this.label}
-          .labelInfo=${this.labelInfo}
-        >
-          <gr-label-info
-            slot="label-info"
-            .change=${this.change}
-            .account=${this.account}
-            .mutable=${this.mutable}
-            .label=${this.label}
-            .labelInfo=${this.labelInfo}
-            .showAllReviewers=${false}
-          ></gr-label-info>
-        </gr-trigger-vote-hovercard>
+        ${this.renderHovercard()}
         <span class="label">${this.label}</span>
         ${this.renderVotes()}
       </div>
     `;
   }
 
+  private renderHovercard() {
+    if (this.disableHovercards) return;
+    return html`<gr-trigger-vote-hovercard
+      .labelName=${this.label}
+      .labelInfo=${this.labelInfo}
+    >
+      <gr-label-info
+        slot="label-info"
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+        .label=${this.label}
+        .labelInfo=${this.labelInfo}
+        .showAllReviewers=${false}
+      ></gr-label-info>
+    </gr-trigger-vote-hovercard>`;
+  }
+
   private renderVotes() {
     const {labelInfo} = this;
     if (!labelInfo) return;
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 64771f5..2680a31 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -265,7 +265,7 @@
 // custom element having the id "app", but it is made explicit here.
 // If you move this code to other place, please update comment about
 // gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed
-const app = document.querySelector('#app');
+const app = document.querySelector('gr-app');
 if (!app) {
   console.info('No gr-app found (running tests)');
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
index 43aefdc..2db7c76 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
@@ -62,7 +62,7 @@
     return html`
       <tr>
         <td class="nameColumn">
-          <a href="${this.getUrlBase(agreement.url)}" rel="external">
+          <a href="${this.getUrlBase(agreement?.url)}" rel="external">
             ${agreement.name}
           </a>
         </td>
@@ -94,8 +94,8 @@
     return `${getBaseUrl()}/settings/new-agreement`;
   }
 
-  getUrlBase(item: string) {
-    return `${getBaseUrl()}/${item}`;
+  getUrlBase(item?: string) {
+    return item ? `${getBaseUrl()}/${item}` : '';
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 92d984d..677e0c1 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -196,4 +196,10 @@
       ? 'hideAgreementsTextBox'
       : '';
   }
+
+  _computeAgreements(serverConfig?: ServerInfo) {
+    return (serverConfig?.auth.contributor_agreements ?? []).filter(
+      agreement => agreement.url
+    );
+  }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
index ce95ccb..564297fa 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -66,10 +66,7 @@
   <main>
     <h1 class="heading-1">New Contributor Agreement</h1>
     <h3 class="heading-3">Select an agreement type:</h3>
-    <template
-      is="dom-repeat"
-      items="[[_serverConfig.auth.contributor_agreements]]"
-    >
+    <template is="dom-repeat" items="[[_computeAgreements(_serverConfig)]]">
       <span class="contributorAgreementButton">
         <input
           id$="claNewAgreementsInput[[item.name]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 417d7fb..c6f44af 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -121,6 +121,7 @@
           border-left-width: var(--spacing-s);
           margin: var(--spacing-m) 0;
           padding: var(--spacing-s) var(--spacing-m);
+          overflow-x: scroll;
         }
         li {
           list-style-type: disc;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
index 9adb8ae..c283876 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -61,14 +61,6 @@
         white-space: var(--linked-text-white-space, pre-wrap);
         word-wrap: var(--linked-text-word-wrap, break-word);
       }
-      :host([disabled]) ::slotted(a) {
-        color: inherit;
-        text-decoration: none;
-        pointer-events: none;
-      }
-      ::slotted(a) {
-        color: var(--link-color);
-      }
     `;
   }
 
@@ -97,6 +89,8 @@
   override updated(changedProperties: PropertyValues): void {
     if (changedProperties.has('content') || changedProperties.has('config')) {
       this._contentOrConfigChanged();
+    } else if (changedProperties.has('disabled')) {
+      this.styleLinks();
     }
   }
 
@@ -131,7 +125,7 @@
     // Ensure links to the same host originating from commentlink configs
     // open in the same tab. When target is not set - default is _self
     // @see Issue 4616
-    this.outputElement!.querySelectorAll('a').forEach(anchor => {
+    this.outputElement.querySelectorAll('a').forEach(anchor => {
       if (anchor.hostname === window.location.hostname) {
         anchor.removeAttribute('target');
       } else {
@@ -139,6 +133,30 @@
       }
       anchor.setAttribute('rel', 'noopener');
     });
+
+    this.styleLinks();
+  }
+
+  /**
+   * Styles the links based on whether gr-linked-text is disabled or not
+   */
+  private styleLinks() {
+    assertIsDefined(this.outputElement);
+    this.outputElement.querySelectorAll('a').forEach(anchor => {
+      anchor.setAttribute('style', this.computeLinkStyle());
+    });
+  }
+
+  private computeLinkStyle() {
+    if (this.disabled) {
+      return `
+        color: inherit;
+        text-decoration: none;
+        pointer-events: none;
+      `;
+    } else {
+      return 'color: var(--link-color)';
+    }
   }
 
   /**
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index dd056c8..515dab8 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -29,6 +29,7 @@
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
+  BULK_ACTIONS = 'UiFeature__bulk_actions_dashboard',
   CHECK_RESULTS_IN_DIFFS = 'UiFeature__check_results_in_diffs',
   DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
 }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 7c922f6..d9c102b 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -177,6 +177,8 @@
     --not-working-hours-icon-background-color: var(--purple-50);
     --not-working-hours-icon-color: var(--purple-700);
     --unavailability-icon-color: var(--gray-700);
+    --unavailability-chip-icon-color: var(--orange-900);
+    --unavailability-chip-background-color: var(--yellow-50);
 
     /* text colors */
     --primary-text-color: var(--gray-900);