Merge "Lit Migration of GrLabeledAutocomplete"
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
index c81b43f..a97560b 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gerrit.common.Nullable;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
@@ -33,6 +34,7 @@
   /**
    * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} ()} on a change.
    */
+  @Nullable
   public abstract SubmitRequirementExpressionResult submittabilityExpressionResult();
 
   /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
@@ -69,7 +71,8 @@
           "Applicability expression result has an error: "
               + applicabilityExpressionResult().get().errorMessage().get());
     }
-    if (submittabilityExpressionResult().errorMessage().isPresent()) {
+    if (submittabilityExpressionResult() != null
+        && submittabilityExpressionResult().errorMessage().isPresent()) {
       return Optional.of(
           "Submittability expression result has an error: "
               + submittabilityExpressionResult().errorMessage().get());
@@ -164,7 +167,8 @@
     public abstract Builder applicabilityExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
 
-    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+    public abstract Builder submittabilityExpressionResult(
+        @Nullable SubmitRequirementExpressionResult value);
 
     public abstract Builder overrideExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
@@ -182,7 +186,7 @@
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
   }
 
-  private boolean assertPass(SubmitRequirementExpressionResult expressionResult) {
+  private boolean assertPass(@Nullable SubmitRequirementExpressionResult expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
   }
 
@@ -194,14 +198,14 @@
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
   }
 
-  private boolean assertError(SubmitRequirementExpressionResult expressionResult) {
+  private boolean assertError(@Nullable SubmitRequirementExpressionResult expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
   }
 
   private boolean assertStatus(
-      SubmitRequirementExpressionResult expressionResult,
+      @Nullable SubmitRequirementExpressionResult expressionResult,
       SubmitRequirementExpressionResult.Status status) {
-    return expressionResult.status() == status;
+    return expressionResult != null && expressionResult.status() == status;
   }
 
   private boolean assertStatus(
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index 3c6bec6..7b2fe2f 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -5,7 +5,10 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
         "//lib:gson",
         "//lib:guava",
+        "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
new file mode 100644
index 0000000..d35b8fb
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
@@ -0,0 +1,150 @@
+// 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.
+
+package com.google.gerrit.json;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * A {@code TypeAdapterFactory} for Optional {@code SubmitRequirementExpressionResult}.
+ *
+ * <p>{@link SubmitRequirementResult#submittabilityExpressionResult} was previously serialized as a
+ * mandatory field, but was later on migrated to an optional field. The server needs to handle
+ * deserializing of both formats.
+ */
+public class OptionalSubmitRequirementExpressionResultAdapterFactory implements TypeAdapterFactory {
+
+  private static final TypeToken<?> OPTIONAL_SR_EXPRESSION_RESULT_TOKEN =
+      TypeToken.get(new TypeLiteral<Optional<SubmitRequirementExpressionResult>>() {}.getType());
+
+  private static final TypeToken<?> SR_EXPRESSION_RESULT_TOKEN =
+      TypeToken.get(SubmitRequirementExpressionResult.class);
+
+  @SuppressWarnings({"unchecked"})
+  @Override
+  public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
+    if (typeToken.equals(OPTIONAL_SR_EXPRESSION_RESULT_TOKEN)) {
+      return (TypeAdapter<T>)
+          new OptionalSubmitRequirementExpressionResultTypeAdapter(
+              SubmitRequirementExpressionResult.typeAdapter(gson));
+    } else if (typeToken.equals(SR_EXPRESSION_RESULT_TOKEN)) {
+      return (TypeAdapter<T>)
+          new SubmitRequirementExpressionResultTypeAdapter(
+              SubmitRequirementExpressionResult.typeAdapter(gson));
+    }
+    return null;
+  }
+
+  /**
+   * Reads json representation of either {@code Optional<SubmitRequirementExpressionResult>} or
+   * {@code SubmitRequirementExpressionResult}, converting it to {@code Nullable} {@code
+   * SubmitRequirementExpressionResult}.
+   */
+  @Nullable
+  private static SubmitRequirementExpressionResult readOptionalOrMandatory(
+      TypeAdapter<SubmitRequirementExpressionResult> submitRequirementExpressionResultAdapter,
+      JsonReader in) {
+    JsonElement parsed = JsonParser.parseReader(in);
+    if (parsed == null) {
+      return null;
+    }
+    // If it does not have 'value' field, then it was serialized as
+    // SubmitRequirementExpressionResult directly
+    if (parsed.getAsJsonObject().has("value")) {
+      parsed = parsed.getAsJsonObject().get("value");
+    }
+    if (parsed == null || parsed.isJsonNull() || parsed.getAsJsonObject().entrySet().isEmpty()) {
+      return null;
+    }
+    return submitRequirementExpressionResultAdapter.fromJsonTree(parsed);
+  }
+
+  /**
+   * A {@code TypeAdapter} that provides backward compatibility for reading previously non-optional
+   * {@code SubmitRequirementExpressionResult} field.
+   */
+  private static class OptionalSubmitRequirementExpressionResultTypeAdapter
+      extends TypeAdapter<Optional<SubmitRequirementExpressionResult>> {
+
+    private final TypeAdapter<SubmitRequirementExpressionResult>
+        submitRequirementExpressionResultAdapter;
+
+    public OptionalSubmitRequirementExpressionResultTypeAdapter(
+        TypeAdapter<SubmitRequirementExpressionResult> submitRequirementResultAdapter) {
+      this.submitRequirementExpressionResultAdapter = submitRequirementResultAdapter;
+    }
+
+    @Override
+    public Optional<SubmitRequirementExpressionResult> read(JsonReader in) throws IOException {
+      return Optional.ofNullable(
+          readOptionalOrMandatory(submitRequirementExpressionResultAdapter, in));
+    }
+
+    @Override
+    public void write(JsonWriter out, Optional<SubmitRequirementExpressionResult> value)
+        throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.beginObject();
+      out.name("value");
+      if (value.isPresent()) {
+        out.jsonValue(submitRequirementExpressionResultAdapter.toJson(value.get()));
+      } else {
+        out.nullValue();
+      }
+      out.endObject();
+    }
+  }
+
+  /**
+   * A {@code TypeAdapter} that provides forward compatibility for reading the optional {@code
+   * SubmitRequirementExpressionResult} field.
+   *
+   * <p>TODO(mariasavtchouk): Remove once updated to read the new format only.
+   */
+  private static class SubmitRequirementExpressionResultTypeAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult> {
+
+    private final TypeAdapter<SubmitRequirementExpressionResult>
+        submitRequirementExpressionResultAdapter;
+
+    public SubmitRequirementExpressionResultTypeAdapter(
+        TypeAdapter<SubmitRequirementExpressionResult> submitRequirementResultAdapter) {
+      this.submitRequirementExpressionResultAdapter = submitRequirementResultAdapter;
+    }
+
+    @Override
+    public SubmitRequirementExpressionResult read(JsonReader in) throws IOException {
+      return readOptionalOrMandatory(submitRequirementExpressionResultAdapter, in);
+    }
+
+    @Override
+    public void write(JsonWriter out, SubmitRequirementExpressionResult value) throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.jsonValue(submitRequirementExpressionResultAdapter.toJson(value));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
index 31d8a15..7793b76 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -40,18 +40,20 @@
               result.applicabilityExpressionResult().get(),
               /* hide= */ true); // Always hide applicability expressions on the API
     }
-    if (req.overrideExpression().isPresent()) {
+    if (req.overrideExpression().isPresent() && result.overrideExpressionResult().isPresent()) {
       info.overrideExpressionResult =
           submitRequirementExpressionToInfo(
               req.overrideExpression().get(),
               result.overrideExpressionResult().get(),
               /* hide= */ false);
     }
-    info.submittabilityExpressionResult =
-        submitRequirementExpressionToInfo(
-            req.submittabilityExpression(),
-            result.submittabilityExpressionResult(),
-            /* hide= */ false);
+    if (result.submittabilityExpressionResult() != null) {
+      info.submittabilityExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.submittabilityExpression(),
+              result.submittabilityExpressionResult(),
+              /* hide= */ false);
+    }
     info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
     info.isLegacy = result.isLegacy();
     return info;
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 44bb244..8b3ab5a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
+import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gson.JsonElement;
@@ -49,6 +50,7 @@
         .registerTypeAdapter(
             new TypeLiteral<Optional<Boolean>>() {}.getType(),
             new OptionalBooleanAdapter().nullSafe())
+        .registerTypeAdapterFactory(new OptionalSubmitRequirementExpressionResultAdapterFactory())
         .registerTypeAdapter(ObjectId.class, new ObjectIdAdapter())
         .setPrettyPrinting()
         .create();
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
index dac71ea..e655d92 100644
--- a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -32,6 +32,8 @@
 
   private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+  private static final FieldDescriptor SR_SUBMITTABILITY_EXPR_RESULT_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(3);
   private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
   private static final FieldDescriptor SR_LEGACY_FIELD =
@@ -56,8 +58,11 @@
           SubmitRequirementExpressionResultSerializer.serialize(
               r.applicabilityExpressionResult().get()));
     }
-    builder.setSubmittabilityExpressionResult(
-        SubmitRequirementExpressionResultSerializer.serialize(r.submittabilityExpressionResult()));
+    if (r.submittabilityExpressionResult() != null) {
+      builder.setSubmittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.submittabilityExpressionResult()));
+    }
     if (r.overrideExpressionResult().isPresent()) {
       builder.setOverrideExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
@@ -85,9 +90,11 @@
               SubmitRequirementExpressionResultSerializer.deserialize(
                   proto.getApplicabilityExpressionResult())));
     }
-    builder.submittabilityExpressionResult(
-        SubmitRequirementExpressionResultSerializer.deserialize(
-            proto.getSubmittabilityExpressionResult()));
+    if (proto.hasField(SR_SUBMITTABILITY_EXPR_RESULT_FIELD)) {
+      builder.submittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.deserialize(
+              proto.getSubmittabilityExpressionResult()));
+    }
     if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
       builder.overrideExpressionResult(
           Optional.of(
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
index 5cd43af..2418d1c 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
@@ -196,12 +196,47 @@
   }
 
   @Test
-  public void submitRequirementResult_deserialize() throws Exception {
+  public void submitRequirementResult_deserialize_nonOptionalSubmittabilityExpressionResultField()
+      throws Exception {
     assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(srReqResultSerial))
         .isEqualTo(srReqResult);
   }
 
   @Test
+  public void submitRequirementResult_deserialize_optionalSubmittabilityExpressionResultField()
+      throws Exception {
+    String newFormatSrReqResultSerial =
+        srReqResultSerial.replace(
+            "\"submittabilityExpressionResult\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]},",
+            "\"submittabilityExpressionResult\":{\"value\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]}},");
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(newFormatSrReqResultSerial))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserialize_emptyOptionalSubmittabilityExpressionResultField()
+      throws Exception {
+    String newFormatSrReqResultSerial =
+        srReqResultSerial.replace(
+            "\"submittabilityExpressionResult\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]},",
+            "\"submittabilityExpressionResult\":{\"value\":null},");
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(newFormatSrReqResultSerial))
+        .isEqualTo(srReqResult.toBuilder().submittabilityExpressionResult(null).build());
+  }
+
+  @Test
   public void submitRequirementResult_roundTrip() throws Exception {
     TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
     assertThat(adapter.fromJson(adapter.toJson(srReqResult))).isEqualTo(srReqResult);
diff --git a/modules/jgit b/modules/jgit
index 4d34cdf..56f45e3 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 4d34cdf3459022d0878dfbd099c6f7b7ea03ea73
+Subproject commit 56f45e36dc2ccb0803ad810098bc4a0ac8f4a675
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 314ea8b..5cbae90 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1079,7 +1079,7 @@
   description?: string;
   status: SubmitRequirementStatus;
   applicability_expression_result?: SubmitRequirementExpressionInfo;
-  submittability_expression_result: SubmitRequirementExpressionInfo;
+  submittability_expression_result?: SubmitRequirementExpressionInfo;
   override_expression_result?: SubmitRequirementExpressionInfo;
   is_legacy?: boolean;
 }
@@ -1092,8 +1092,9 @@
 export declare interface SubmitRequirementExpressionInfo {
   expression: string;
   fulfilled: boolean;
-  passing_atoms: string[];
-  failing_atoms: string[];
+  passing_atoms?: string[];
+  failing_atoms?: string[];
+  error_message?: string;
 }
 
 /**
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
index c8f5813..9ce0da8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
@@ -26,6 +26,7 @@
   createParsedChange,
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {
   SubmitRequirementResultInfo,
@@ -48,7 +49,10 @@
     };
     change = {
       ...createParsedChange(),
-      submit_requirements: [submitRequirement],
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
       labels: {
         Verified: {
           ...createDetailedLabelInfo(),
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 8932d52..ab8d2d7 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
@@ -25,6 +25,7 @@
   createChange,
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {
@@ -483,7 +484,10 @@
     };
     const change: ChangeInfo = {
       ...createChange(),
-      submit_requirements: [submitRequirement],
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
       unresolved_comment_count: 1,
     };
     const element = await fixture<GrChangeListItem>(
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
new file mode 100644
index 0000000..9492fa9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {
+  ChangeMessage,
+  LabelExtreme,
+  PATCH_SET_PREFIX_PATTERN,
+} from '../../../utils/comment-util';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const VOTE_RESET_TEXT = '0 (vote reset)';
+
+interface Score {
+  label?: string;
+  value?: string;
+}
+
+export const LABEL_TITLE_SCORE_PATTERN =
+  /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.:]?$/;
+
+@customElement('gr-message-scores')
+export class GrMessageScores extends LitElement {
+  @property()
+  labelExtremes?: LabelExtreme;
+
+  @property({type: Object})
+  message?: ChangeMessage;
+
+  static override get styles() {
+    return css`
+      .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 {
+        background-color: var(--vote-color-neutral);
+      }
+      .score.negative {
+        background-color: var(--vote-color-disliked);
+        border: 1px solid var(--vote-outline-disliked);
+        line-height: calc(var(--line-height-normal) - 2px);
+        color: var(--chip-color);
+      }
+      .score.negative.min {
+        background-color: var(--vote-color-rejected);
+        border: none;
+        padding-top: 1px;
+        padding-bottom: 1px;
+        color: var(--vote-text-color);
+      }
+      .score.positive {
+        background-color: var(--vote-color-recommended);
+        border: 1px solid var(--vote-outline-recommended);
+        line-height: calc(var(--line-height-normal) - 2px);
+        color: var(--chip-color);
+      }
+      .score.positive.max {
+        background-color: var(--vote-color-approved);
+        border: none;
+        padding-top: 1px;
+        padding-bottom: 1px;
+        color: var(--vote-text-color);
+      }
+
+      @media screen and (max-width: 50em) {
+        .score {
+          min-width: 0px;
+        }
+      }
+    `;
+  }
+
+  override render() {
+    const scores = this._getScores(this.message, this.labelExtremes);
+    return scores.map(score => this.renderScore(score));
+  }
+
+  private renderScore(score: Score) {
+    return html`<span
+      class="score ${this._computeScoreClass(score, this.labelExtremes)}"
+    >
+      ${score.label} ${score.value}
+    </span>`;
+  }
+
+  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
+    // Polymer 2: check for undefined
+    if (score === undefined || labelExtremes === undefined) {
+      return '';
+    }
+    if (!score.value) {
+      return '';
+    }
+    if (score.value.includes(VOTE_RESET_TEXT)) {
+      return 'removed';
+    }
+    const classes = [];
+    if (Number(score.value) > 0) {
+      classes.push('positive');
+    } else if (Number(score.value) < 0) {
+      classes.push('negative');
+    }
+    if (score.label) {
+      const extremes = labelExtremes[score.label];
+      if (extremes) {
+        const intScore = Number(score.value);
+        if (intScore === extremes.max) {
+          classes.push('max');
+        } else if (intScore === extremes.min) {
+          classes.push('min');
+        }
+      }
+    }
+    return classes.join(' ');
+  }
+
+  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
+    if (!message || !message.message || !labelExtremes) {
+      return [];
+    }
+    const line = message.message.split('\n', 1)[0];
+    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+    if (!line.match(patchSetPrefix)) {
+      return [];
+    }
+    const scoresRaw = line.split(patchSetPrefix)[1];
+    if (!scoresRaw) {
+      return [];
+    }
+    return scoresRaw
+      .split(' ')
+      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+      .filter(
+        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
+      )
+      .map(ms => {
+        const label = ms?.[2];
+        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
+        return {label, value};
+      });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-message-scores': GrMessageScores;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts
new file mode 100644
index 0000000..7398909
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-message-scores';
+import {createChangeMessage} from '../../../test/test-data-generators';
+import {queryAll} from '../../../test/test-utils';
+import {GrMessageScores} from './gr-message-scores';
+
+const basicFixture = fixtureFromElement('gr-message-scores');
+
+suite('gr-message-score tests', () => {
+  let element: GrMessageScores;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('votes', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+    };
+    element.labelExtremes = {
+      Verified: {max: 1, min: -1},
+      'Code-Review': {max: 2, min: -2},
+      'Trybot-Label3': {max: 3, min: 0},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 3);
+
+    assert.isTrue(scoreChips[0].classList.contains('positive'));
+    assert.isTrue(scoreChips[0].classList.contains('max'));
+
+    assert.isTrue(scoreChips[1].classList.contains('negative'));
+    assert.isTrue(scoreChips[1].classList.contains('min'));
+
+    assert.isTrue(scoreChips[2].classList.contains('positive'));
+    assert.isFalse(scoreChips[2].classList.contains('min'));
+  });
+
+  test('Uploaded patch set X', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message:
+        'Uploaded patch set 1:' +
+        'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+    };
+    element.labelExtremes = {
+      Verified: {max: 1, min: -1},
+      'Code-Review': {max: 2, min: -2},
+      'Trybot-Label3': {max: 3, min: 0},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 3);
+
+    assert.isTrue(scoreChips[0].classList.contains('positive'));
+    assert.isTrue(scoreChips[0].classList.contains('max'));
+
+    assert.isTrue(scoreChips[1].classList.contains('negative'));
+    assert.isTrue(scoreChips[1].classList.contains('min'));
+
+    assert.isTrue(scoreChips[2].classList.contains('positive'));
+    assert.isFalse(scoreChips[2].classList.contains('min'));
+  });
+
+  test('Uploaded and rebased', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Uploaded patch set 4: Commit-Queue+1: Patch Set 3 was rebased.',
+    };
+    element.labelExtremes = {
+      'Commit-Queue': {max: 2, min: -2},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 1);
+    assert.isTrue(scoreChips[0].classList.contains('positive'));
+  });
+
+  test('removed votes', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
+    };
+    element.labelExtremes = {
+      Verified: {max: 1, min: -1},
+      'Code-Review': {max: 2, min: -2},
+      'Commit-Queue': {max: 3, min: 0},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 3);
+
+    assert.isTrue(scoreChips[1].classList.contains('removed'));
+    assert.isTrue(scoreChips[2].classList.contains('removed'));
+  });
+
+  test('false negative vote', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+    };
+    element.labelExtremes = {};
+    await element.updateComplete;
+    const scoreChips = element.shadowRoot?.querySelectorAll('.score');
+    assert.equal(scoreChips?.length, 0);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index cc7cf88..8664de3 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -22,26 +22,29 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../../styles/shared-styles';
+import '../gr-message-scores/gr-message-scores';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
 import {MessageTag, SpecialFilePath} from '../../../constants/constants';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
-  ChangeMessageInfo,
   ServerInfo,
   ConfigInfo,
   RepoName,
   ReviewInputTag,
-  VotingRangeInfo,
   NumericChangeId,
   ChangeMessageId,
   PatchSetNum,
   AccountInfo,
   BasePatchSetNum,
 } from '../../../types/common';
-import {CommentThread} from '../../../utils/comment-util';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  ChangeMessage,
+  CommentThread,
+  LabelExtreme,
+  PATCH_SET_PREFIX_PATTERN,
+} from '../../../utils/comment-util';
 import {getAppContext} from '../../../services/app-context';
 import {pluralize} from '../../../utils/string-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -52,12 +55,8 @@
 } from '../../../utils/patch-set-util';
 import {isServiceUser, replaceTemplates} from '../../../utils/account-util';
 
-const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
-const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.:]?$/;
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
-const VOTE_RESET_TEXT = '0 (vote reset)';
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-message': GrMessage;
@@ -68,20 +67,6 @@
   id: ChangeMessageId;
 }
 
-export interface ChangeMessage extends ChangeMessageInfo {
-  // TODO(TS): maybe should be an enum instead
-  type: string;
-  expanded: boolean;
-  commentThreads: CommentThread[];
-}
-
-export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
-
-interface Score {
-  label?: string;
-  value?: string;
-}
-
 @customElement('gr-message')
 export class GrMessage extends PolymerElement {
   static get template() {
@@ -444,63 +429,6 @@
     return message.type === 'REVIEWER_UPDATE';
   }
 
-  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
-    if (!message || !message.message || !labelExtremes) {
-      return [];
-    }
-    const line = message.message.split('\n', 1)[0];
-    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-    if (!line.match(patchSetPrefix)) {
-      return [];
-    }
-    const scoresRaw = line.split(patchSetPrefix)[1];
-    if (!scoresRaw) {
-      return [];
-    }
-    return scoresRaw
-      .split(' ')
-      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
-      .filter(
-        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
-      )
-      .map(ms => {
-        const label = ms?.[2];
-        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
-        return {label, value};
-      });
-  }
-
-  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
-    // Polymer 2: check for undefined
-    if (score === undefined || labelExtremes === undefined) {
-      return '';
-    }
-    if (!score.value) {
-      return '';
-    }
-    if (score.value.includes(VOTE_RESET_TEXT)) {
-      return 'removed';
-    }
-    const classes = [];
-    if (Number(score.value) > 0) {
-      classes.push('positive');
-    } else if (Number(score.value) < 0) {
-      classes.push('negative');
-    }
-    if (score.label) {
-      const extremes = labelExtremes[score.label];
-      if (extremes) {
-        const intScore = Number(score.value);
-        if (intScore === extremes.max) {
-          classes.push('max');
-        } else if (intScore === extremes.min) {
-          classes.push('min');
-        }
-      }
-    }
-    return classes.join(' ');
-  }
-
   _computeClass(expanded?: boolean, author?: AccountInfo) {
     const classes = [];
     classes.push(expanded ? 'expanded' : 'collapsed');
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 8def279..628af83 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
@@ -144,15 +144,6 @@
       cursor: pointer;
       vertical-align: top;
     }
-    .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;
-    }
-    .score,
     .commentsSummary {
       margin-right: var(--spacing-s);
       min-width: 115px;
@@ -163,35 +154,6 @@
     .commentsIcon {
       vertical-align: top;
     }
-    .score.removed {
-      background-color: var(--vote-color-neutral);
-    }
-    .score.negative {
-      background-color: var(--vote-color-disliked);
-      border: 1px solid var(--vote-outline-disliked);
-      line-height: calc(var(--line-height-normal) - 2px);
-      color: var(--chip-color);
-    }
-    .score.negative.min {
-      background-color: var(--vote-color-rejected);
-      border: none;
-      padding-top: 1px;
-      padding-bottom: 1px;
-      color: var(--vote-text-color);
-    }
-    .score.positive {
-      background-color: var(--vote-color-recommended);
-      border: 1px solid var(--vote-outline-recommended);
-      line-height: calc(var(--line-height-normal) - 2px);
-      color: var(--chip-color);
-    }
-    .score.positive.max {
-      background-color: var(--vote-color-approved);
-      border: none;
-      padding-top: 1px;
-      padding-bottom: 1px;
-      color: var(--vote-text-color);
-    }
     gr-account-label::part(gr-account-label-text) {
       font-weight: var(--font-weight-bold);
     }
@@ -203,7 +165,6 @@
       .expanded .content {
         padding-left: 0;
       }
-      .score,
       .commentsSummary {
         min-width: 0px;
       }
@@ -226,15 +187,10 @@
           account="[[author]]"
           class="authorLabel"
         ></gr-account-label>
-        <template
-          is="dom-repeat"
-          items="[[_getScores(message, labelExtremes)]]"
-          as="score"
-        >
-          <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
-            [[score.label]] [[score.value]]
-          </span>
-        </template>
+        <gr-message-scores
+          label-extremes="[[labelExtremes]]"
+          message="[[message]]"
+        ></gr-message-scores>
       </div>
       <template is="dom-if" if="[[_commentCountText]]">
         <div class="commentsSummary">
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 0fd39d2..2acc2a8 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -28,7 +28,6 @@
 import {
   mockPromise,
   query,
-  queryAll,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
@@ -463,109 +462,6 @@
         assert.equal(actual, expected);
       });
     });
-
-    test('votes', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        Verified: {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = queryAll(element, '.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('Uploaded patch set X', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message:
-          'Uploaded patch set 1:' +
-          'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        Verified: {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = queryAll(element, '.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('Uploaded and rebased', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message:
-          'Uploaded patch set 4: Commit-Queue+1: Patch Set 3 was rebased.',
-      };
-      element.labelExtremes = {
-        'Commit-Queue': {max: 2, min: -2},
-      };
-      flush();
-      const scoreChips = queryAll(element, '.score');
-      assert.equal(scoreChips.length, 1);
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-    });
-
-    test('removed votes', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
-      };
-      element.labelExtremes = {
-        Verified: {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Commit-Queue': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = queryAll(element, '.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[1].classList.contains('removed'));
-      assert.isTrue(scoreChips[2].classList.contains('removed'));
-    });
-
-    test('false negative vote', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
-      };
-      element.labelExtremes = {};
-      const scoreChips = element.root!.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 0);
-    });
   });
 
   suite('when not logged in', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 3c1baa6..851cf9a 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -41,7 +41,11 @@
   ReviewerUpdateInfo,
   VotingRangeInfo,
 } from '../../../types/common';
-import {CommentThread, isRobot} from '../../../utils/comment-util';
+import {
+  CommentThread,
+  isRobot,
+  LabelExtreme,
+} from '../../../utils/comment-util';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
@@ -249,7 +253,7 @@
   _combinedMessages: CombinedMessage[] = [];
 
   @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
+  _labelExtremes: LabelExtreme = {};
 
   private readonly userModel = getAppContext().userModel;
 
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 15a971a1..8fa2fa4 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
@@ -16,9 +16,10 @@
  */
 import '../../shared/gr-label-info/gr-label-info';
 import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
-import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
+import '../gr-trigger-vote/gr-trigger-vote';
 import '../gr-change-summary/gr-change-summary';
 import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-vote-chip/gr-vote-chip';
 import {LitElement, css, html, TemplateResult} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -26,7 +27,6 @@
   AccountInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
-  LabelInfo,
   LabelNameToInfoMap,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
@@ -47,7 +47,6 @@
 import {CheckRun} from '../../../models/checks/checks-model';
 import {getResultsOf, hasResultsOf} from '../../../models/checks/checks-util';
 import {Category} from '../../../api/checks';
-import '../../shared/gr-vote-chip/gr-vote-chip';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import {PrimaryTab} from '../../../constants/constants';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
@@ -371,103 +370,8 @@
   }
 }
 
-@customElement('gr-trigger-vote')
-export class GrTriggerVote extends LitElement {
-  @property()
-  label?: string;
-
-  @property({type: Object})
-  labelInfo?: LabelInfo;
-
-  @property({type: Object})
-  change?: ParsedChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  static override get styles() {
-    return css`
-      :host {
-        display: block;
-      }
-      .container {
-        box-sizing: border-box;
-        border: 1px solid var(--border-color);
-        border-radius: calc(var(--border-radius) + 2px);
-        background-color: var(--background-color-primary);
-        display: flex;
-        padding: 0;
-        padding-left: var(--spacing-s);
-        padding-right: var(--spacing-xxs);
-        align-items: center;
-      }
-      .label {
-        padding-right: var(--spacing-s);
-        font-weight: var(--font-weight-bold);
-      }
-      gr-vote-chip {
-        --gr-vote-chip-width: 14px;
-        --gr-vote-chip-height: 14px;
-        margin-right: 0px;
-        margin-left: var(--spacing-xs);
-      }
-      gr-vote-chip:first-of-type {
-        margin-left: 0px;
-      }
-    `;
-  }
-
-  override render() {
-    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>
-        <span class="label">${this.label}</span>
-        ${this.renderVotes()}
-      </div>
-    `;
-  }
-
-  private renderVotes() {
-    const {labelInfo} = this;
-    if (!labelInfo) return;
-    if (isDetailedLabelInfo(labelInfo)) {
-      const approvals = getAllUniqueApprovals(labelInfo).filter(
-        approval => !hasNeutralStatus(labelInfo, approval)
-      );
-      return approvals.map(
-        approvalInfo => html`<gr-vote-chip
-          .vote="${approvalInfo}"
-          .label="${labelInfo}"
-        ></gr-vote-chip>`
-      );
-    } else if (isQuickLabelInfo(labelInfo)) {
-      return [html`<gr-vote-chip .label="${this.labelInfo}"></gr-vote-chip>`];
-    } else {
-      return html``;
-    }
-  }
-}
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-submit-requirements': GrSubmitRequirements;
-    'gr-trigger-vote': GrTriggerVote;
   }
 }
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 323c70f..37fa767 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
@@ -27,6 +27,7 @@
   createParsedChange,
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {SubmitRequirementResultInfo} from '../../../api/rest-api';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -44,7 +45,10 @@
     };
     const change: ParsedChangeInfo = {
       ...createParsedChange(),
-      submit_requirements: [submitRequirement],
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
       labels: {
         Verified: {
           ...createDetailedLabelInfo(),
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
new file mode 100644
index 0000000..d920b46
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../shared/gr-label-info/gr-label-info';
+import '../../shared/gr-vote-chip/gr-vote-chip';
+import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {ParsedChangeInfo} from '../../../types/types';
+import {
+  AccountInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+} from '../../../api/rest-api';
+import {
+  getAllUniqueApprovals,
+  hasNeutralStatus,
+} from '../../../utils/label-util';
+
+@customElement('gr-trigger-vote')
+export class GrTriggerVote extends LitElement {
+  @property()
+  label?: string;
+
+  @property({type: Object})
+  labelInfo?: LabelInfo;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Boolean})
+  mutable?: boolean;
+
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      .container {
+        box-sizing: border-box;
+        border: 1px solid var(--border-color);
+        border-radius: calc(var(--border-radius) + 2px);
+        background-color: var(--background-color-primary);
+        display: flex;
+        padding: 0;
+        padding-left: var(--spacing-s);
+        padding-right: var(--spacing-xxs);
+        align-items: center;
+      }
+      .label {
+        padding-right: var(--spacing-s);
+        font-weight: var(--font-weight-bold);
+      }
+      gr-vote-chip {
+        --gr-vote-chip-width: 14px;
+        --gr-vote-chip-height: 14px;
+        margin-right: 0px;
+        margin-left: var(--spacing-xs);
+      }
+      gr-vote-chip:first-of-type {
+        margin-left: 0px;
+      }
+    `;
+  }
+
+  override render() {
+    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>
+        <span class="label">${this.label}</span>
+        ${this.renderVotes()}
+      </div>
+    `;
+  }
+
+  private renderVotes() {
+    const {labelInfo} = this;
+    if (!labelInfo) return;
+    if (isDetailedLabelInfo(labelInfo)) {
+      const approvals = getAllUniqueApprovals(labelInfo).filter(
+        approval => !hasNeutralStatus(labelInfo, approval)
+      );
+      return approvals.map(
+        approvalInfo => html`<gr-vote-chip
+          .vote="${approvalInfo}"
+          .label="${labelInfo}"
+        ></gr-vote-chip>`
+      );
+    } else if (isQuickLabelInfo(labelInfo)) {
+      return [html`<gr-vote-chip .label="${this.labelInfo}"></gr-vote-chip>`];
+    } else {
+      return html``;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-trigger-vote': GrTriggerVote;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
new file mode 100644
index 0000000..cd25da0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-trigger-vote';
+import {GrTriggerVote} from './gr-trigger-vote';
+import {
+  createAccountWithIdNameAndEmail,
+  createApproval,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-trigger-vote tests', () => {
+  let element: GrTriggerVote;
+  setup(async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: {
+        ...createSubmitRequirementExpressionInfo(),
+        expression: 'label:Verified=MAX -label:Verified=MIN',
+      },
+    };
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+    const account = createAccountWithIdNameAndEmail();
+    const label = 'Verified';
+    const labelInfo = change?.labels?.[label];
+    element = await fixture<GrTriggerVote>(
+      html`<gr-trigger-vote
+        .label="${label}"
+        .labelInfo="${labelInfo}"
+        .change="${change}"
+        .account="${account}"
+        .mutable="${false}"
+      ></gr-trigger-vote>`
+    );
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(`<div class="container">
+      <gr-trigger-vote-hovercard>
+        <gr-label-info slot="label-info"></gr-label-info>
+      </gr-trigger-vote-hovercard>
+      <span class="label">
+        Verified
+      </span>
+      <gr-vote-chip>
+      </gr-vote-chip>
+    </div>`);
+  });
+});
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index d0179ef..1abb0b9 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -103,6 +103,7 @@
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
 import {
+  ChangeMessage,
   CommentThread,
   createCommentThreads,
   DraftInfo,
@@ -111,7 +112,6 @@
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
-import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 import {
   GenerateUrlEditViewParameters,
   GenerateUrlTopicViewParams,
@@ -782,6 +782,15 @@
   };
 }
 
+export function createNonApplicableSubmitRequirementResultInfo(): SubmitRequirementResultInfo {
+  return {
+    name: 'Verified',
+    status: SubmitRequirementStatus.NOT_APPLICABLE,
+    applicability_expression_result: createSubmitRequirementExpressionInfo(),
+    is_legacy: false,
+  };
+}
+
 export function createRunResult(): RunResult {
   return {
     attemptDetails: [],
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 3a46e60..6ad9e71 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,11 +15,10 @@
  * limitations under the License.
  */
 import {PatchSetNum} from './common';
-import {Comment} from '../utils/comment-util';
+import {ChangeMessage, Comment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
-import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
   BIND_VALUE_CHANGED = 'bind-value-changed',
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index ee26915..501da7d 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -29,6 +29,8 @@
   RevisionPatchSetNum,
   AccountInfo,
   AccountDetailInfo,
+  ChangeMessageInfo,
+  VotingRangeInfo,
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -89,6 +91,18 @@
   id: UrlEncodedCommentId;
 }
 
+export interface ChangeMessage extends ChangeMessageInfo {
+  // TODO(TS): maybe should be an enum instead
+  type: string;
+  expanded: boolean;
+  commentThreads: CommentThread[];
+}
+
+export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
+
+export const PATCH_SET_PREFIX_PATTERN =
+  /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
+
 export function sortComments<T extends SortableComment>(comments: T[]): T[] {
   return comments.slice(0).sort((c1, c2) => {
     const d1 = isDraft(c1);
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 82380849..49790e6 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -232,7 +232,7 @@
   type: 'all' | 'onlyOverride' | 'onlySubmittability' = 'all'
 ): string[] {
   let labels: string[] = [];
-  if (type !== 'onlyOverride') {
+  if (requirement.submittability_expression_result && type !== 'onlyOverride') {
     labels = labels.concat(
       extractLabelsFrom(requirement.submittability_expression_result.expression)
     );
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index fbd0aa1..4e6d2f6 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -43,12 +43,10 @@
   createChange,
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
   createDetailedLabelInfo,
 } from '../test/test-data-generators';
-import {
-  SubmitRequirementResultInfo,
-  SubmitRequirementStatus,
-} from '../api/rest-api';
+import {SubmitRequirementResultInfo} from '../api/rest-api';
 
 const VALUES_0 = {
   '0': 'neutral',
@@ -281,6 +279,12 @@
       const labels = extractAssociatedLabels(submitRequirement);
       assert.deepEqual(labels, ['Verified', 'Build-cop-override']);
     });
+    test('non-applicable that has no labels', () => {
+      const submitRequirement =
+        createNonApplicableSubmitRequirementResultInfo();
+      const labels = extractAssociatedLabels(submitRequirement);
+      assert.deepEqual(labels, []);
+    });
   });
 
   suite('getRequirements()', () => {
@@ -314,10 +318,7 @@
     });
     test('filter not applicable', () => {
       const requirement = createSubmitRequirementResultInfo();
-      const requirement2 = {
-        ...createSubmitRequirementResultInfo(),
-        status: SubmitRequirementStatus.NOT_APPLICABLE,
-      };
+      const requirement2 = createNonApplicableSubmitRequirementResultInfo();
       const change = createChangeInfoWith([requirement, requirement2]);
       assert.deepEqual(getRequirements(change), [requirement]);
     });
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 3432eb9..3c5ba88 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -36,18 +36,18 @@
         sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.7.0"
+    SSHD_VERS = "2.8.0"
 
     maven_jar(
         name = "sshd-osgi",
         artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
-        sha1 = "a101aad0f79ad424498098f7e91c39d3d92177c1",
+        sha1 = "b2a59b73c045f40d5722b9160d4f909a646d86c9",
     )
 
     maven_jar(
         name = "sshd-sftp",
         artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS,
-        sha1 = "0c9eff7145e20b338c1dd6aca36ba93ed7c0147c",
+        sha1 = "d3cd9bc8d335b3ed1a86d2965deb4d202de27442",
     )
 
     maven_jar(
@@ -65,7 +65,7 @@
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "22799941ec7bd5170ea890363cb968e400a69c41",
+        sha1 = "02f78100cce376198be798a37c84aaf945e8a0f7",
     )
 
     maven_jar(