Merge "Set "@typescript-eslint/no-floating-promises" to off in plugin eslint config"
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 5d40e2d..fadbad9 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -724,6 +724,22 @@
 Valid relations are >=, >, \<=, <, or no relation, which will match if the number of unresolved
 comments is exactly equal.
 
+[[unmet_requirement]]
+unmet_requirement:'SUBMIT_REQUIREMENT_NAME'::
++
+Matches changes where the given submit requirement is evaluated and unmet (its result is UNSATISFIED, ERROR, or TIMEOUT). The requirement name comparison is case-insensitive.
+
+[[unsatisfied_requirement_count]]
+unsatisfied_requirement_count:'RELATION''NUMBER'::
++
+True if the number of unsatisfied submit requirements satisfies the given relation for the given number.
++
+For example, unsatisfied_requirement_count:>0 will be true for any change which has at least one unsatisfied
+submit requirement while unsatisfied_requirement_count:0 will be true for any change which has all submit requirements resolved.
++
+Valid relations are >=, >, \<=, <, or no relation, which will match if the number of unsatisfied
+submit requirements is exactly equal.
+
 == Argument Quoting
 
 Operator values that are not bare words (roughly A-Z, a-z, 0-9, @,
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index e26165a..f7f1ce1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -1674,6 +1674,51 @@
       STORED_SUBMIT_REQUIREMENTS_SPEC =
           STORED_SUBMIT_REQUIREMENTS_FIELD.storedOnly("full_submit_requirements");
 
+  /**
+   * Names of submit requirements that are not fulfilled for a change (i.e., whose evaluation status
+   * is {@link com.google.gerrit.entities.SubmitRequirementResult.Status#UNSATISFIED}, {@link
+   * com.google.gerrit.entities.SubmitRequirementResult.Status#ERROR}, or {@link
+   * com.google.gerrit.entities.SubmitRequirementResult.Status#TIMEOUT}).
+   *
+   * <p>Note that if evaluating a submit requirement results in an ERROR or TIMEOUT, it is
+   * considered unfulfilled and its name will be included in this field.
+   */
+  public static final IndexedField<ChangeData, Iterable<String>> UNMET_REQUIREMENT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("UnmetRequirement")
+          .build(
+              cd ->
+                  cd.submitRequirementsIncludingLegacy().values().stream()
+                      .filter(sr -> !sr.fulfilled())
+                      .map(sr -> sr.submitRequirement().name().toLowerCase(Locale.US))
+                      .collect(toImmutableSet()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec UNMET_REQUIREMENT_SPEC =
+      UNMET_REQUIREMENT_FIELD.exact("unmet_requirement");
+
+  /**
+   * The number of submit requirements that are not fulfilled for a change (i.e., whose evaluation
+   * status is {@link com.google.gerrit.entities.SubmitRequirementResult.Status#UNSATISFIED}, {@link
+   * com.google.gerrit.entities.SubmitRequirementResult.Status#ERROR}, or {@link
+   * com.google.gerrit.entities.SubmitRequirementResult.Status#TIMEOUT}).
+   *
+   * <p>Note that if evaluating a submit requirement results in an ERROR or TIMEOUT, it is
+   * considered unfulfilled and is counted here.
+   */
+  public static final IndexedField<ChangeData, Integer> UNSATISFIED_REQUIREMENT_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("UnsatisfiedRequirementCount")
+          .stored()
+          .build(
+              cd ->
+                  (int)
+                      cd.submitRequirementsIncludingLegacy().values().stream()
+                          .filter(sr -> !sr.fulfilled())
+                          .count(),
+              (cd, field) -> cd.setUnsatisfiedRequirementCount(field));
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec
+      UNSATISFIED_REQUIREMENT_COUNT_SPEC =
+          UNSATISFIED_REQUIREMENT_COUNT_FIELD.integerRange("unsatisfied_requirement_count");
+
   private static void parseSubmitRequirements(
       Iterable<Cache.SubmitRequirementResultProto> values, ChangeData out) {
     out.setSubmitRequirements(
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index aaa7535..809dd0a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -274,6 +274,7 @@
   @Deprecated static final Schema<ChangeData> V87 = schema(V86);
 
   /** Add REVIEWERS_COUNT_FIELD */
+  @Deprecated
   static final Schema<ChangeData> V88 =
       new Schema.Builder<ChangeData>()
           .add(V87)
@@ -281,6 +282,16 @@
           .addSearchSpecs(ChangeField.REVIEWER_COUNT_SPEC)
           .build();
 
+  /** Add met and unmet requirement tracking fields */
+  static final Schema<ChangeData> V89 =
+      new Schema.Builder<ChangeData>()
+          .add(V88)
+          .addIndexedFields(
+              ChangeField.UNMET_REQUIREMENT_FIELD, ChangeField.UNSATISFIED_REQUIREMENT_COUNT_FIELD)
+          .addSearchSpecs(
+              ChangeField.UNMET_REQUIREMENT_SPEC, ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC)
+          .build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index a3b1e3e..6dee3eb 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -443,6 +443,7 @@
       Maps.newLinkedHashMapWithExpectedSize(1);
 
   private Map<SubmitRequirement, SubmitRequirementResult> submitRequirements;
+  private Integer unsatisfiedRequirementCount;
 
   private StorageConstraint storageConstraint = StorageConstraint.NOTEDB_ONLY;
   private Change change;
@@ -1307,6 +1308,14 @@
         projectConfigReqs, legacyReqs, this);
   }
 
+  public Integer unsatisfiedRequirementCount() {
+    return unsatisfiedRequirementCount;
+  }
+
+  public void setUnsatisfiedRequirementCount(Integer count) {
+    this.unsatisfiedRequirementCount = count;
+  }
+
   /**
    * Get all evaluated submit requirements for this change, including those from parent projects.
    * For closed changes, submit requirements are read from the change notes. For active changes,
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index fdaa061..6331323 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -242,6 +242,12 @@
         ChangeField.HASHTAG_SPEC, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
   }
 
+  /** Returns a predicate that matches changes that have an unmet submit requirement. */
+  public static Predicate<ChangeData> unmetRequirement(String requirementName) {
+    return new ChangeIndexPredicate(
+        ChangeField.UNMET_REQUIREMENT_SPEC, requirementName.toLowerCase(Locale.US));
+  }
+
   /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
   public static Predicate<ChangeData> fuzzyHashtag(String hashtag) {
     // Use toLowerCase without locale to match behavior in ChangeField.
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 6311c65..68530ef 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -1460,6 +1460,21 @@
   }
 
   @Operator
+  public Predicate<ChangeData> unmet_requirement(String requirementName)
+      throws QueryParseException {
+    checkFieldAvailable(ChangeField.UNMET_REQUIREMENT_SPEC, "unmet_requirement");
+    return ChangePredicates.unmetRequirement(requirementName);
+  }
+
+  @Operator
+  public Predicate<ChangeData> unsatisfied_requirement_count(String value)
+      throws QueryParseException {
+    checkFieldAvailable(
+        ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC, "unsatisfied_requirement_count");
+    return new UnsatisfiedRequirementCountPredicate(value);
+  }
+
+  @Operator
   public Predicate<ChangeData> cc(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     return reviewerByState(who, ReviewerStateInternal.CC, false);
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index 26ccb2c..4ca53c5 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -131,6 +131,21 @@
   }
 
   @Override
+  public Predicate<ChangeData> unmet_requirement(String requirementName)
+      throws QueryParseException {
+    throw new QueryParseException(
+        "Operator 'unmet_requirement' cannot be used in submit requirement expressions.");
+  }
+
+  @Override
+  public Predicate<ChangeData> unsatisfied_requirement_count(String value)
+      throws QueryParseException {
+    throw new QueryParseException(
+        "Operator 'unsatisfied_requirement_count' cannot be used in submit requirement"
+            + " expressions.");
+  }
+
+  @Override
   public Predicate<ChangeData> has(String value) throws QueryParseException {
     if (value.toLowerCase(Locale.US).startsWith(SUBMODULE_UPDATE_HAS_ARG)) {
       List<String> args = SUBMODULE_UPDATE_SPLITTER.splitToList(value);
diff --git a/java/com/google/gerrit/server/query/change/UnsatisfiedRequirementCountPredicate.java b/java/com/google/gerrit/server/query/change/UnsatisfiedRequirementCountPredicate.java
new file mode 100644
index 0000000..827558e
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/UnsatisfiedRequirementCountPredicate.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class UnsatisfiedRequirementCountPredicate extends IntegerRangeChangePredicate {
+  public UnsatisfiedRequirementCountPredicate(String value) throws QueryParseException {
+    super(ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC, value);
+  }
+
+  @Override
+  protected Integer getValueInt(ChangeData changeData) {
+    Integer count = changeData.unsatisfiedRequirementCount();
+    if (count != null) {
+      return count;
+    }
+    return ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC.get(changeData);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 5bf1a40..b8d06ef 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -2783,6 +2783,58 @@
   }
 
   @Test
+  public void submitRequirement_disallowsUnsatisfiedRequirementCountInExpression()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Wrong-Req")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("unsatisfied_requirement_count:0"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("Wrong-Req"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult.errorMessage)
+        .isEqualTo(
+            "Operator 'unsatisfied_requirement_count' cannot be used in submit requirement"
+                + " expressions.");
+  }
+
+  @Test
+  public void submitRequirement_disallowsUnmetRequirementInExpression() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Wrong-Req")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("unmet_requirement:Code-Review"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("Wrong-Req"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult.errorMessage)
+        .isEqualTo(
+            "Operator 'unmet_requirement' cannot be used in submit requirement expressions.");
+  }
+
+  @Test
   public void submitRequirements_forcedByDirectSubmission() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 53431d1..6fcbf5d 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -105,6 +105,29 @@
   }
 
   @Test
+  public void unmetRequirementField() {
+    SubmitRequirementResult sr1 =
+        submitRequirementResult(
+            "Code-Review", "label:CR=+1", SubmitRequirementExpressionResult.Status.PASS);
+    SubmitRequirementResult sr2 =
+        submitRequirementResult(
+            "Verified", "label:V=+1", SubmitRequirementExpressionResult.Status.FAIL);
+
+    assertThat(sr1.fulfilled()).isTrue();
+    assertThat(sr2.fulfilled()).isFalse();
+
+    ChangeData cd = org.mockito.Mockito.mock(ChangeData.class);
+    org.mockito.Mockito.when(cd.submitRequirementsIncludingLegacy())
+        .thenReturn(
+            com.google.common.collect.ImmutableMap.of(
+                sr1.submitRequirement(), sr1,
+                sr2.submitRequirement(), sr2));
+
+    assertThat(ChangeField.UNMET_REQUIREMENT_FIELD.get(cd)).containsExactly("verified");
+    assertThat(ChangeField.UNSATISFIED_REQUIREMENT_COUNT_FIELD.get(cd)).isEqualTo(1);
+  }
+
+  @Test
   public void storedSubmitRecords() {
     assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 8d1e3dd..059be1b 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -4559,6 +4559,35 @@
   }
 
   @Test
+  public void bySubmitRequirement_unmet() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UNMET_REQUIREMENT_SPEC)).isTrue();
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    assertQuery("unmet_requirement:Code-Review", change1);
+
+    approve(change1);
+    assertQuery("unmet_requirement:Code-Review");
+  }
+
+  @Test
+  public void bySubmitRequirement_unsatisfiedCount() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC)).isTrue();
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    assertQuery("unsatisfied_requirement_count:>0", change1);
+    assertQuery("unsatisfied_requirement_count:1", change1);
+    assertQuery("unsatisfied_requirement_count:<=1", change1);
+    assertQuery("unmet_requirement:Code-Review", change1);
+
+    approve(change1);
+    assertQuery("unsatisfied_requirement_count:0", change1);
+    assertQuery("unsatisfied_requirement_count:<1", change1);
+    assertQuery("unmet_requirement:Code-Review");
+  }
+
+  @Test
   public void byUrlEncodedProject() throws Exception {
     Project.NameKey project = Project.nameKey("repo+foo");
     repo = createAndOpenProject(project);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index adfc532..9c7ec81 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -536,6 +536,7 @@
         .headerLeft {
           display: flex;
           align-items: center;
+          min-width: 0;
         }
         gr-patch-range-select {
           display: block;
@@ -571,7 +572,9 @@
           margin-right: var(--spacing-m);
           font-weight: var(--font-weight-medium);
           white-space: nowrap;
-          overflow: auto;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          flex-shrink: 100;
         }
         .patchRangeLeft {
           align-items: center;
@@ -595,6 +598,8 @@
         .jumpToFileContainer {
           display: inline-block;
           word-break: break-all;
+          min-width: 0;
+          overflow: hidden;
         }
         .mobile {
           display: none;
@@ -920,11 +925,9 @@
             >${this.changeNum}</a
           ><span class="changeNumberColon">:</span>
         </div>
-        <div>
-          <span class="headerSubject"
-            >${trimWithEllipsis(this.change?.subject, 80)}</span
-          >
-        </div>
+        <span class="headerSubject"
+          >${trimWithEllipsis(this.change?.subject, 80)}</span
+        >
         <div class="checkboxDiv">
           <md-checkbox
             id="reviewed"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 1d5a9b5..e19a98c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -246,9 +246,7 @@
                   <a href="/c/test-project/+/42"> 42 </a>
                   <span class="changeNumberColon"> : </span>
                 </div>
-                <div>
-                  <span class="headerSubject"> Test subject </span>
-                </div>
+                <span class="headerSubject"> Test subject </span>
                 <div class="checkboxDiv">
                   <md-checkbox
                     class="hideOnEdit reviewed"
@@ -421,6 +419,51 @@
       );
     });
 
+    test('nav links stay inside the header when space runs out', async () => {
+      element.change = {
+        ...createParsedChange(),
+        subject:
+          'A change subject that is long enough to overflow a narrow header',
+      };
+      element.path =
+        'polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts';
+      element.files = getFilesFromFileList([element.path]);
+      const forceDesktop = document.createElement('style');
+      forceDesktop.textContent = '.navLinks.desktop { display: flex; }';
+      element.shadowRoot!.appendChild(forceDesktop);
+      await element.updateComplete;
+
+      const header = queryAndAssert(element, 'header');
+      const navLinks = queryAndAssert(element, '.navLinks');
+      const subject = queryAndAssert(element, '.headerSubject');
+      assert.notEqual(getComputedStyle(navLinks).display, 'none');
+      const naturalSubjectWidth = subject.getBoundingClientRect().width;
+
+      element.style.width = '600px';
+      await element.updateComplete;
+
+      assert.isAtMost(
+        navLinks.getBoundingClientRect().right,
+        header.getBoundingClientRect().right,
+        'nav links overflow the header'
+      );
+      assert.isAtMost(
+        header.scrollWidth,
+        header.clientWidth,
+        'header content overflows the header box'
+      );
+      assert.isBelow(
+        subject.getBoundingClientRect().width,
+        naturalSubjectWidth,
+        'subject should absorb the width deficit'
+      );
+      assert.isAbove(
+        subject.scrollWidth,
+        subject.clientWidth,
+        'subject should be ellipsized rather than pushing the nav links out'
+      );
+    });
+
     test('keyboard shortcuts', async () => {
       clock = sinon.useFakeTimers({
         toFake: ['Date'],