Merge "Add hovercard endpoint for buganizer hovercard plugin"
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index b85545a..0cb407e 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -2410,6 +2410,8 @@
 metadata entries with the same name may be returned.
 |`value`      |optional|The metadata value.
 |`description`|optional|A description of the metadata.
+|`web_links`  |optional|A list of web links as
+link:rest-api-changes.html#web-link-info[WebLinkInfo] entities.
 |==========================
 
 [[plugin-config-info]]
diff --git a/java/com/google/gerrit/acceptance/GerritServerRestSession.java b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
index c2c77fe..9605f50 100644
--- a/java/com/google/gerrit/acceptance/GerritServerRestSession.java
+++ b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
@@ -142,7 +142,8 @@
     return execute(delete);
   }
 
-  private String getUrl(String endPoint) {
+  @Override
+  public String getUrl(String endPoint) {
     return url + (account != null ? "/a" : "") + endPoint;
   }
 }
diff --git a/java/com/google/gerrit/acceptance/RestResponse.java b/java/com/google/gerrit/acceptance/RestResponse.java
index a9c14aa..a53c015 100644
--- a/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/java/com/google/gerrit/acceptance/RestResponse.java
@@ -103,4 +103,9 @@
     assertStatus(SC_MOVED_TEMPORARILY);
     assertThat(URI.create(getHeader("Location")).getPath()).isEqualTo(path);
   }
+
+  public void assertTemporaryRedirectUri(String uri) throws Exception {
+    assertStatus(SC_MOVED_TEMPORARILY);
+    assertThat(getHeader("Location")).isEqualTo(uri);
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 0865e31..3fefd5b 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -52,4 +52,6 @@
   RestResponse delete(String endPoint) throws Exception;
 
   RestResponse deleteWithHeaders(String endPoint, Header... headers) throws Exception;
+
+  String getUrl(String endPoint);
 }
diff --git a/java/com/google/gerrit/extensions/common/MetadataInfo.java b/java/com/google/gerrit/extensions/common/MetadataInfo.java
index a2cdb98..2213191 100644
--- a/java/com/google/gerrit/extensions/common/MetadataInfo.java
+++ b/java/com/google/gerrit/extensions/common/MetadataInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.Nullable;
+import java.util.List;
 import java.util.Objects;
 
 /**
@@ -37,18 +38,22 @@
   /** A description of the metadata. May be unset. */
   @Nullable public String description;
 
+  /** Web links. May be unset. */
+  @Nullable public List<WebLinkInfo> webLinks;
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
         .add("name", name)
         .add("value", value)
         .add("description", description)
+        .add("webLinks", webLinks)
         .toString();
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(name, value, description);
+    return Objects.hash(name, value, description, webLinks);
   }
 
   @Override
@@ -57,7 +62,8 @@
       MetadataInfo metadata = (MetadataInfo) o;
       return Objects.equals(name, metadata.name)
           && Objects.equals(value, metadata.value)
-          && Objects.equals(description, metadata.description);
+          && Objects.equals(description, metadata.description)
+          && Objects.equals(webLinks, metadata.webLinks);
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 9925a66..0c2691a 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -122,7 +122,7 @@
    */
   public static <T> LinkedBindingBuilder<T> bind(Binder binder, Class<T> type, Named name) {
     binder.disableCircularProxies();
-    return bind(binder, TypeLiteral.get(type));
+    return bind(binder, TypeLiteral.get(type), name);
   }
 
   /**
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index 2c80c9b..ca937fd 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.entities.Change;
@@ -85,6 +86,7 @@
     if (finalSegment != null) {
       path += finalSegment;
     }
-    UrlModule.toGerrit(path, req, rsp);
+    String queryString = Strings.emptyToNull(req.getQueryString());
+    UrlModule.toGerrit(path + (queryString != null ? "?" + queryString : ""), req, rsp);
   }
 }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index aedf8e9..f62fbe8 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -45,6 +45,9 @@
 class UrlModule extends ServletModule {
   private final AuthConfig authConfig;
 
+  private static final String CHANGE_NUMBER_REGEX = "(?:/c)?/([1-9][0-9]*)";
+  private static final String PATCH_SET_REGEX = "([1-9][0-9]*(\\.\\.[1-9][0-9]*)?)";
+
   UrlModule(AuthConfig authConfig) {
     this.authConfig = authConfig;
   }
@@ -72,7 +75,12 @@
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
     serveRegex("^/register$").with(registerScreen(false));
     serveRegex("^/register/(.+)$").with(registerScreen(true));
-    serveRegex("^(?:/c)?/([1-9][0-9]*)/?.*$").with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "(/" + PATCH_SET_REGEX + ")?/?$")
+        .with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "/" + PATCH_SET_REGEX + "?/[^+]+$")
+        .with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "/comment/\\w+/?$")
+        .with(NumericChangeIdRedirectServlet.class);
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
diff --git a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
index 1e043b1..1316066 100644
--- a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
+++ b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
@@ -22,6 +22,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.io.PrintWriter;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -52,8 +53,9 @@
         res.setContentType("image/svg+xml");
         res.setCharacterEncoding(UTF_8.name());
         res.setStatus(HttpServletResponse.SC_OK);
-        res.getWriter().write(responseToClient);
-        res.getWriter().flush();
+        PrintWriter writer = res.getWriter();
+        writer.write(responseToClient);
+        writer.flush();
       } else {
         res.setContentLength(0);
         res.setStatus(HttpServletResponse.SC_NO_CONTENT);
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index b00294f..42e10d8 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -63,7 +63,17 @@
 
 public class StaticModule extends ServletModule {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  public static final String CHANGE_NUMBER_URI_REGEX = "^(?:/c)?/([1-9][0-9]*)/?.*";
+  // This constant is copied and NOT reused from UrlModule because of the need for
+  // StaticModule and UrlModule to be used in isolation. The requirement comes
+  // from the way Google includes these two classes in their setup.
+  private static final String CHANGE_NUMBER_REGEX = "(?:/c)?/([1-9][0-9]*)";
+  // Regex matching the direct links to comments using only the change number
+  // 1234/comment/abc_def
+  public static final String CHANGE_NUMBER_URI_REGEX =
+      "^"
+          + CHANGE_NUMBER_REGEX
+          + "(/[1-9][0-9]*(\\.\\.[1-9][0-9]*)?(/[^+]*)?)?(/comment/[^+]+)?/?$";
+
   private static final Pattern CHANGE_NUMBER_URI_PATTERN = Pattern.compile(CHANGE_NUMBER_URI_REGEX);
 
   /**
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index e800d17..d393a89 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -104,6 +104,7 @@
   private Injector sysInjector;
   private Injector cfgInjector;
   private Config globalConfig;
+  private boolean reuseExistingDocuments;
 
   @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
   @Inject private DynamicMap<Cache<?, ?>> cacheMap;
@@ -120,6 +121,10 @@
     cfgInjector = dbInjector.createChildInjector();
     globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     overrideConfig();
+    reuseExistingDocuments =
+        reuseExistingDocumentsOption != null
+            ? reuseExistingDocumentsOption
+            : globalConfig.getBoolean("index", null, "reuseExistingDocuments", false);
     LifecycleManager dbManager = new LifecycleManager();
     dbManager.add(dbInjector);
     dbManager.start();
@@ -221,7 +226,8 @@
             super.configure();
             OptionalBinder.newOptionalBinder(binder(), IsFirstInsertForEntry.class)
                 .setBinding()
-                .toInstance(IsFirstInsertForEntry.YES);
+                .toInstance(
+                    reuseExistingDocuments ? IsFirstInsertForEntry.NO : IsFirstInsertForEntry.YES);
             OptionalBinder.newOptionalBinder(binder(), BuildBloomFilter.class)
                 .setBinding()
                 .toInstance(buildBloomFilter ? BuildBloomFilter.TRUE : BuildBloomFilter.FALSE);
@@ -265,10 +271,6 @@
     requireNonNull(
         index, () -> String.format("no active search index configured for %s", def.getName()));
     index.markReady(false);
-    boolean reuseExistingDocuments =
-        reuseExistingDocumentsOption != null
-            ? reuseExistingDocumentsOption
-            : globalConfig.getBoolean("index", null, "reuseExistingDocuments", false);
 
     if (!reuseExistingDocuments) {
       index.deleteAll();
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 914bdd2..43270df 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -23,11 +23,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto;
@@ -44,9 +46,11 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
@@ -61,6 +65,8 @@
   private static final String EXTERNAL_NAME = "groups_external";
   private static final String PERSISTED_EXTERNAL_NAME = "groups_external_persisted";
 
+  private final IndexConfig indexConfig;
+
   public static Module module() {
     return new CacheModule() {
       @Override
@@ -114,10 +120,12 @@
           LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember,
       @Named(PARENT_GROUPS_NAME)
           LoadingCache<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> parentGroups,
-      @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
+      @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external,
+      IndexConfig indexConfig) {
     this.groupsWithMember = groupsWithMember;
     this.parentGroups = parentGroups;
     this.external = external;
+    this.indexConfig = indexConfig;
   }
 
   @Override
@@ -144,7 +152,10 @@
   public Collection<AccountGroup.UUID> parentGroupsOf(Set<AccountGroup.UUID> groupIds) {
     try {
       Set<AccountGroup.UUID> parents = new HashSet<>();
-      parentGroups.getAll(groupIds).values().forEach(p -> parents.addAll(p));
+      for (List<AccountGroup.UUID> groupIdsBatch :
+          Lists.partition(new ArrayList<>(groupIds), indexConfig.maxTerms())) {
+        parentGroups.getAll(groupIdsBatch).values().forEach(p -> parents.addAll(p));
+      }
       return parents;
     } catch (ExecutionException e) {
       logger.atWarning().withCause(e).log("Cannot load included groups");
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 2331255..fb02de6 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -222,6 +222,9 @@
         isIndexed.set(i);
         newChildren.add(c);
       } else if (nc == null /* cannot rewrite c */) {
+        if (c instanceof ChangeDataSource) {
+          changeSource.set(i);
+        }
         notIndexed.set(i);
         newChildren.add(c);
       } else {
@@ -236,6 +239,9 @@
     if (isIndexed.cardinality() == n) {
       return in; // All children are indexed, leave as-is for parent.
     } else if (notIndexed.cardinality() == n) {
+      if (changeSource.cardinality() == n) {
+        return copy(in, newChildren);
+      }
       return null; // Can't rewrite any children, so cannot rewrite in.
     } else if (rewritten.cardinality() == n) {
       // All children were rewritten.
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/patch/ApplyPatchUtil.java
similarity index 99%
rename from java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
rename to java/com/google/gerrit/server/patch/ApplyPatchUtil.java
index 8b8aaa6..a319a11 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
+++ b/java/com/google/gerrit/server/patch/ApplyPatchUtil.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.change;
+package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index 8d5247d..6b7f563 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.ApplyPatchUtil;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 746313b..4c3c7b0 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -74,6 +74,7 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ApplyPatchUtil;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index eff783e..1d2d048 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -56,7 +56,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.restapi.change.ApplyPatchUtil;
+import com.google.gerrit.server.patch.ApplyPatchUtil;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index 8011fb0..a54c7ca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -454,12 +454,21 @@
     ChangeData changeData = createChange().getChange();
     int changeNumber = changeData.getId().get();
 
-    String finalSegment = "any/Thing";
+    assertChangeNumberWithSuffixRedirected(changeNumber, "1..2");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2/COMMIT_MSG");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2?foo=bar");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2/path/to/source/file/MyClass.java");
+  }
 
-    String redirectUri = String.format("/c/%s/+/%d/%s", project.get(), changeNumber, finalSegment);
+  private void assertChangeNumberWithSuffixRedirected(int changeNumber, String suffix)
+      throws Exception {
+    String redirectUri =
+        anonymousRestSession.getUrl(
+            String.format("/c/%s/+/%d/%s", project.get(), changeNumber, suffix));
     anonymousRestSession
-        .get(String.format("/c/%d/%s", changeNumber, finalSegment))
-        .assertTemporaryRedirect(redirectUri);
+        .get(String.format("/c/%d/%s", changeNumber, suffix))
+        .assertTemporaryRedirectUri(redirectUri);
   }
 
   @Test
@@ -467,12 +476,12 @@
     int changeNumber = createChange().getChange().getId().get();
     String commentId = "ff3303fd_8341647b";
 
-    String redirectUri =
+    String redirectPath =
         String.format("/c/%s/+/%d/comment/%s", project.get(), changeNumber, commentId);
 
     anonymousRestSession
         .get(String.format("/%s/comment/%s", changeNumber, commentId))
-        .assertTemporaryRedirect(redirectUri);
+        .assertTemporaryRedirect(redirectPath);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 20ed40d..04093a5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -83,7 +83,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.restapi.change.ApplyPatchUtil;
+import com.google.gerrit.server.patch.ApplyPatchUtil;
 import com.google.gerrit.server.restapi.change.CreateChange;
 import com.google.gerrit.server.restapi.change.CreateChange.CommitTreeSupplier;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
diff --git a/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
index 28ec30d..e973a26 100644
--- a/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
@@ -15,19 +15,33 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.raw.StaticModule.PolyGerritFilter.isPolyGerritIndex;
 
-import com.google.common.collect.ImmutableList;
 import org.junit.Test;
 
 public class StaticModuleTest {
 
   @Test
   public void doNotMatchPolyGerritIndex() {
-    ImmutableList.of(
-            "/c/123456/anyString",
-            "/123456/anyString",
-            "/c/123456/comment/9ab75172_67d798e1",
-            "/123456/comment/9ab75172_67d798e1")
-        .forEach(url -> assertThat(StaticModule.PolyGerritFilter.isPolyGerritIndex(url)).isFalse());
+    assertThat(isPolyGerritIndex("/123456")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1/")).isFalse();
+    assertThat(isPolyGerritIndex("/c/123456/comment/9ab75172_67d798e1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/comment/9ab75172_67d798e1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/comment/9ab75172_67d798e1/")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1..2")).isFalse();
+    assertThat(isPolyGerritIndex("/c/123456/1..2")).isFalse();
+    assertThat(isPolyGerritIndex("/c/2/1/COMMIT_MSG")).isFalse();
+    assertThat(isPolyGerritIndex("/c/2/1/path/to/source/file/MyClass.java")).isFalse();
+  }
+
+  @Test
+  public void matchPolyGerritIndex() {
+    assertThat(isPolyGerritIndex("/c/test/+/123456/anyString")).isTrue();
+    assertThat(isPolyGerritIndex("/c/test/+/123456/comment/9ab75172_67d798e1")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/+/123456/anyString")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/+/123456/comment/9ab75172_67d798e1")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/anyString")).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 26e9e54..c65e552 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.OrSource;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.Set;
@@ -46,6 +47,7 @@
 
 public class ChangeIndexRewriterTest {
   private static final IndexConfig CONFIG = IndexConfig.createDefault();
+  private static final int MAX_INDEX_QUERY_TERMS = 4;
 
   private FakeChangeIndex index;
   private ChangeIndexCollection indexes;
@@ -58,7 +60,9 @@
     indexes = new ChangeIndexCollection();
     indexes.setSearchIndex(index);
     queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.builder().maxTerms(3).build());
+    rewrite =
+        new ChangeIndexRewriter(
+            indexes, IndexConfig.builder().maxTerms(MAX_INDEX_QUERY_TERMS).build());
   }
 
   @Test
@@ -71,7 +75,7 @@
   public void nonIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(
             query(
@@ -90,10 +94,24 @@
   }
 
   @Test
+  public void indexedOrSourceSubexpressions() throws Exception {
+    Predicate<ChangeData> in = parse("(file:a bar:b) OR (file:c bar:d)");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(OrSource.class);
+    assertThat(out.getChildCount()).isEqualTo(2);
+    assertThat(out.getChild(0).getChildren())
+        .containsExactly(query(parse("file:a")), parse("bar:b"))
+        .inOrder();
+    assertThat(out.getChild(1).getChildren())
+        .containsExactly(query(parse("file:c")), parse("bar:d"))
+        .inOrder();
+  }
+
+  @Test
   public void nonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("foo:a OR foo:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(
             query(
@@ -106,10 +124,26 @@
   }
 
   @Test
+  public void nonIndexOrSourcePredicates() throws Exception {
+    Predicate<ChangeData> in = parse("baz:a OR baz:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(OrSource.class);
+    assertThat(out.getChildren()).containsExactly(parse("baz:a"), parse("baz:b")).inOrder();
+  }
+
+  @Test
+  public void nonIndexAndSourcePredicates() throws Exception {
+    Predicate<ChangeData> in = parse("baz:a baz:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
+    assertThat(out.getChildren()).containsExactly(parse("baz:a"), parse("baz:b")).inOrder();
+  }
+
+  @Test
   public void oneIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a file:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren()).containsExactly(query(parse("file:b")), parse("foo:a")).inOrder();
   }
 
@@ -167,7 +201,7 @@
   public void indexAndNonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("status:new bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(query(andCardinal(parse("status:new"), parse("file:a"))), parse("bar:p"))
         .inOrder();
@@ -239,12 +273,12 @@
 
   @Test
   public void tooManyTerms() throws Exception {
-    String q = "file:a OR file:b OR file:c";
+    String q = "file:a OR file:b OR file:c OR file:d";
     Predicate<ChangeData> in = parse(q);
     assertEquals(query(in), rewrite(in));
 
     QueryParseException thrown =
-        assertThrows(QueryParseException.class, () -> rewrite(parse(q + " OR file:d")));
+        assertThrows(QueryParseException.class, () -> rewrite(parse(q + " OR file:e")));
     assertThat(thrown).hasMessageThat().contains("too many terms in query");
   }
 
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 90a9b9d..d816719 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -14,16 +14,48 @@
 
 package com.google.gerrit.server.index.change;
 
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.OperatorPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Ignore;
 
 @Ignore
 public class FakeQueryBuilder extends ChangeQueryBuilder {
+  public static class FakeNonIndexSourcePredicate extends OperatorPredicate<ChangeData>
+      implements ChangeDataSource {
+    private static final String operator = "baz";
+
+    public FakeNonIndexSourcePredicate(String value) {
+      super(operator, value);
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() {
+      return null;
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() {
+      return null;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 0;
+    }
+
+    @Override
+    public boolean hasChange() {
+      return false;
+    }
+  }
+
   FakeQueryBuilder(ChangeIndexCollection indexes) {
     super(
         new QueryBuilder.Definition<>(FakeQueryBuilder.class),
@@ -64,6 +96,11 @@
   }
 
   @Operator
+  public Predicate<ChangeData> baz(String value) {
+    return new FakeNonIndexSourcePredicate(value);
+  }
+
+  @Operator
   public Predicate<ChangeData> foo(String value) {
     return predicate("foo", value);
   }
diff --git a/modules/jgit b/modules/jgit
index 692ccfc..1cd87ab 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 692ccfc0c29d53afc7a0b82f41efcd999ed217b0
+Subproject commit 1cd87ab79065b78a0774f20f1bfd522747c37c15
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 783adf7..e5e9ece 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 783adf7ddf19924d054a1596eec6dd3da9f4aafe
+Subproject commit e5e9ece112242397f000660c6cee8f5053ca5da5
diff --git a/plugins/package.json b/plugins/package.json
index 78f990d..3da1b7a 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -19,6 +19,7 @@
     "@codemirror/lang-rust": "^6.0.1",
     "@codemirror/lang-sass": "^6.0.2",
     "@codemirror/lang-sql": "^6.7.0",
+    "@codemirror/lang-vue": "^0.1.3",
     "@codemirror/lang-xml": "^6.1.0",
     "@codemirror/lang-yaml": "^6.1.1",
     "@codemirror/language": "^6.10.2",
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 3391423..c6d2b18 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -222,7 +222,7 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@codemirror/lang-vue@^0.1.1":
+"@codemirror/lang-vue@^0.1.1", "@codemirror/lang-vue@^0.1.3":
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz#bf79b9152cc18b4903d64c1f67e186ae045c8a97"
   integrity sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 044693e..382a043 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -671,6 +671,16 @@
 export type EmailAddress = BrandType<string, '_emailAddress'>;
 
 /**
+ * The EmailInfo entity contains information about an email address of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#email-info
+ */
+export declare interface EmailInfo {
+  email: EmailAddress;
+  preferred?: boolean;
+  pending_confirmation?: boolean;
+}
+
+/**
  * The FetchInfo entity contains information about how to fetch a patchset via
  * a certain protocol.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
@@ -1084,6 +1094,7 @@
   user: UserConfigInfo;
   default_theme?: string;
   submit_requirement_dashboard_columns?: string[];
+  metadata?: MetadataInfo[];
 }
 
 /**
@@ -1094,6 +1105,17 @@
  */
 export type SshdInfo = {};
 
+/**
+ * The MetadataInfo entity contains contains metadata provided by plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#metadata-info
+ */
+export declare interface MetadataInfo {
+  name: string;
+  value?: string;
+  description?: string;
+  web_links?: WebLinkInfo[];
+}
+
 // Timestamps are given in UTC and have the format
 // "'yyyy-mm-dd hh:mm:ss.fffffffff'"
 // where "'ffffffffff'" represents nanoseconds.
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 21e032b..58827fb 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -17,6 +17,7 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
+import '../gr-server-info/gr-server-info';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   AccountDetailInfo,
@@ -213,6 +214,7 @@
       ${this.renderGroupMembers()} ${this.renderGroupAuditLog()}
       ${this.renderRepoDetailList()} ${this.renderRepoCommands()}
       ${this.renderRepoAccess()} ${this.renderRepoDashboards()}
+      ${this.renderServerInfo()}
     `;
   }
 
@@ -447,6 +449,18 @@
     `;
   }
 
+  private renderServerInfo() {
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.SERVER_INFO)
+      return nothing;
+
+    return html`
+      <div class="main table">
+        <gr-server-info class="table"></gr-server-info>
+      </div>
+    `;
+  }
+
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('groupId')) {
       this.computeGroupName();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index d184f35..cbac9de 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -84,7 +84,7 @@
       Promise.resolve(createAdminCapabilities())
     );
     await element.reload();
-    assert.equal(element.filteredLinks!.length, 3);
+    assert.equal(element.filteredLinks!.length, 4);
 
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
@@ -98,7 +98,7 @@
 
   test('filteredLinks non admin authenticated', async () => {
     await element.reload();
-    assert.equal(element.filteredLinks!.length, 2);
+    assert.equal(element.filteredLinks!.length, 3);
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
     // Groups
@@ -162,7 +162,7 @@
     );
     await element.reload();
     await element.updateComplete;
-    assert.equal(queryAll<HTMLLIElement>(element, '.sectionTitle').length, 3);
+    assert.equal(queryAll<HTMLLIElement>(element, '.sectionTitle').length, 4);
     assert.equal(
       queryAndAssert<HTMLSpanElement>(element, '.breadcrumbText').innerText,
       'Test Repo'
@@ -189,7 +189,7 @@
     );
     await element.reload();
     await element.updateComplete;
-    assert.equal(element.filteredLinks!.length, 3);
+    assert.equal(element.filteredLinks!.length, 4);
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
     // Groups
@@ -385,6 +385,12 @@
         url: '/admin/plugins',
         view: 'gr-plugin-list' as GerritView,
       },
+      {
+        name: 'Server Info',
+        section: 'Server Info',
+        url: '/admin/server-info',
+        view: 'gr-server-info' as GerritView,
+      },
     ];
     const expectedSubsectionLinks = [
       {
@@ -532,6 +538,11 @@
                   Plugins
                 </a>
               </li>
+              <li class="sectionTitle">
+                <a class="title" href="/admin/server-info" rel="noopener">
+                  Server Info
+                </a>
+              </li>
             </ul>
           </gr-page-nav>
           <div class="main table">
diff --git a/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts b/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts
new file mode 100644
index 0000000..b67239e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts
@@ -0,0 +1,172 @@
+/**
+ * @license
+ * Copyright (C) 2024 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-weblink/gr-weblink';
+import {MetadataInfo, ServerInfo, WebLinkInfo} from '../../../types/common';
+import {configModelToken} from '../../../models/config/config-model';
+import {customElement, state} from 'lit/decorators.js';
+import {css, html, LitElement} from 'lit';
+import {fireTitleChange} from '../../../utils/event-util';
+import {map} from 'lit/directives/map.js';
+import {resolve} from '../../../models/dependency';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {tableStyles} from '../../../styles/gr-table-styles';
+
+@customElement('gr-server-info')
+export class GrServerInfo extends LitElement {
+  @state() serverInfo?: ServerInfo;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverInfo => {
+        this.serverInfo = serverInfo;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      css`
+        .genericList tr td:last-of-type {
+          text-align: left;
+        }
+        .genericList tr th:last-of-type {
+          text-align: left;
+        }
+        .metadataDescription,
+        .metadataName,
+        .metadataValue,
+        .metadataWebLinks {
+          white-space: nowrap;
+        }
+        .placeholder {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    fireTitleChange('Server Info');
+  }
+
+  override render() {
+    return html`
+      <main class="gr-form-styles read-only">
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="metadataName topHeader">Name</th>
+              <th class="metadataValue topHeader">Value</th>
+              <th class="metadataWebLinks topHeader">Links</th>
+              <th class="metadataDescription topHeader">Description</th>
+            </tr>
+          </tbody>
+          ${this.renderServerInfoTable()}
+        </table>
+      </main>
+    `;
+  }
+
+  private renderServerInfoTable() {
+    return html`
+      <tbody>
+        ${map(this.getServerInfoAsMetadataInfos(), metadata =>
+          this.renderServerInfo(metadata)
+        )}
+      </tbody>
+    `;
+  }
+
+  private renderServerInfo(metadata: MetadataInfo) {
+    return html`
+      <tr class="table">
+        <td class="metadataName">${metadata.name}</td>
+        <td class="metadataValue">
+          ${metadata.value
+            ? metadata.value
+            : html`<span class="placeholder">--</span>`}
+        </td>
+        <td class="metadataWebLinks">
+          ${metadata.web_links
+            ? map(metadata.web_links, webLink => this.renderWebLink(webLink))
+            : ''}
+        </td>
+        <td class="metadataDescription">
+          ${metadata.description ? metadata.description : ''}
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderWebLink(info: WebLinkInfo) {
+    return html`<p><gr-weblink imageAndText .info=${info}></gr-weblink></p>`;
+  }
+
+  private getServerInfoAsMetadataInfos() {
+    let metadataList = new Array<MetadataInfo>();
+
+    const accountsVisibilityMetadata = this.createAccountVisibilityMetadata();
+    if (accountsVisibilityMetadata) {
+      metadataList.push(accountsVisibilityMetadata);
+    }
+
+    if (this.serverInfo?.metadata) {
+      metadataList = metadataList.concat(this.serverInfo.metadata);
+    }
+
+    return metadataList;
+  }
+
+  private createAccountVisibilityMetadata(): MetadataInfo | undefined {
+    if (this.serverInfo?.accounts?.visibility) {
+      const accountsVisibilityMetadata = {
+        name: 'accounts.visibility',
+        value: this.serverInfo.accounts.visibility,
+        description:
+          "Controls visibility of other users' dashboard pages and completion suggestions to web users.",
+        web_links: new Array<WebLinkInfo>(),
+      };
+      if (this.serverInfo?.gerrit?.doc_url) {
+        const docWebLink = {
+          name: 'Documentation',
+          url:
+            this.serverInfo.gerrit.doc_url +
+            'config-gerrit.html#accounts.visibility',
+        };
+        accountsVisibilityMetadata.web_links.push(docWebLink);
+      }
+      return accountsVisibilityMetadata;
+    }
+    return undefined;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-server-info': GrServerInfo;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 91770ca..0799e03 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -172,7 +172,7 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('changeSection')) {
+    if (changedProperties.has('changeSection') && this.isLoggedIn) {
       // In case the list of changes is updated due to auto reloading, we want
       // to ensure the model removes any stale change that is not a part of the
       // new section changes.
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
index 4655c71..ae55a4d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -19,6 +19,7 @@
   ChangeInfoId,
   ChangeStatus,
   CommitId,
+  EmailAddress,
   GitRef,
   HttpMethod,
   NumericChangeId,
@@ -67,11 +68,11 @@
 
 const emails = [
   {
-    email: 'primary@email.com',
+    email: 'primary@email.com' as EmailAddress,
     preferred: true,
   },
   {
-    email: 'secondary@email.com',
+    email: 'secondary@email.com' as EmailAddress,
     preferred: false,
   },
 ];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index 038fcd5..1c2a89d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -174,7 +174,7 @@
     test('hide rebaseWithCommitterEmail dialog when committer has single email', async () => {
       element.committerEmailDropdownItems = [
         {
-          email: 'test1@example.com',
+          email: 'test1@example.com' as EmailAddress,
           preferred: true,
           pending_confirmation: true,
         },
@@ -186,12 +186,12 @@
     test('show rebaseWithCommitterEmail dialog when committer has more than one email', async () => {
       element.committerEmailDropdownItems = [
         {
-          email: 'test1@example.com',
+          email: 'test1@example.com' as EmailAddress,
           preferred: true,
           pending_confirmation: true,
         },
         {
-          email: 'test2@example.com',
+          email: 'test2@example.com' as EmailAddress,
           pending_confirmation: true,
         },
       ];
@@ -230,12 +230,12 @@
       };
       element.committerEmailDropdownItems = [
         {
-          email: 'currentuser1@example.com',
+          email: 'currentuser1@example.com' as EmailAddress,
           preferred: true,
           pending_confirmation: true,
         },
         {
-          email: 'currentuser2@example.com',
+          email: 'currentuser2@example.com' as EmailAddress,
           pending_confirmation: true,
         },
       ];
@@ -264,12 +264,12 @@
       };
       element.committerEmailDropdownItems = [
         {
-          email: 'uploader1@example.com',
+          email: 'uploader1@example.com' as EmailAddress,
           preferred: true,
           pending_confirmation: true,
         },
         {
-          email: 'uploader2@example.com',
+          email: 'uploader2@example.com' as EmailAddress,
           preferred: false,
           pending_confirmation: true,
         },
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 30f4bf8..28ca5fd 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -46,6 +46,7 @@
   AdminViewModel,
   AdminViewState,
   PLUGIN_LIST_ROUTE,
+  SERVER_INFO_ROUTE,
 } from '../../../models/views/admin';
 import {
   AgreementViewModel,
@@ -178,6 +179,9 @@
   // Matches /admin/repos/$REPO,tags with optional filter and offset.
   TAG_LIST: /^\/admin\/repos\/(.+),tags\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
 
+  // Matches /admin/server-info.
+  SERVER_INFO: /^\/admin\/server-info$/,
+
   QUERY: /^\/q\/(.+?)(,(\d+))?$/,
 
   /**
@@ -930,6 +934,13 @@
       this.handlePluginScreen(ctx)
     );
 
+    this.mapRouteState(
+      SERVER_INFO_ROUTE,
+      this.adminViewModel,
+      'handleServerInfoRoute',
+      true
+    );
+
     this.mapRoute(
       RoutePattern.DOCUMENTATION_SEARCH_FILTER,
       'handleDocumentationSearchRoute',
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 6f4e528..4a78140 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -161,6 +161,7 @@
       'handlePluginListRoute',
       'handleRepoCommandsRoute',
       'handleRepoEditFileRoute',
+      'handleServerInfoRoute',
       'handleSettingsLegacyRoute',
       'handleSettingsRoute',
     ];
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 4814733..29d2bf0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -127,6 +127,7 @@
     'line-selected': CustomEvent<LineSelectedEventDetail>;
     // Fired if being logged in is required.
     'show-auth-required': CustomEvent<{}>;
+    'reload-diff': CustomEvent<{path: string | undefined}>;
   }
 }
 
@@ -343,6 +344,11 @@
     this.addEventListener('diff-context-expanded', event =>
       this.handleDiffContextExpanded(event)
     );
+    this.addEventListener('reload-diff', (e: CustomEvent) => {
+      if (e.detail.path === this.path) {
+        this.reload(false);
+      }
+    });
     subscribe(
       this,
       () => this.getBrowserModel().diffViewMode$,
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index 84ed8dc..12321c2 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -8,6 +8,7 @@
 import {GrEmailEditor} from './gr-email-editor';
 import {spyRestApi, stubRestApi} from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
+import {EmailAddress} from '../../../api/rest-api';
 
 suite('gr-email-editor tests', () => {
   let element: GrEmailEditor;
@@ -15,9 +16,9 @@
 
   setup(async () => {
     const emails = [
-      {email: 'email@one.com'},
-      {email: 'email@two.com', preferred: true},
-      {email: 'email@three.com'},
+      {email: 'email@one.com' as EmailAddress},
+      {email: 'email@two.com' as EmailAddress, preferred: true},
+      {email: 'email@three.com' as EmailAddress},
     ];
 
     accountEmailStub = stubRestApi('getAccountEmails').returns(
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index d238881..b55c9fd 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -91,7 +91,7 @@
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {deepEqual} from '../../../utils/deep-util';
 import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
-import {waitUntil} from '../../../utils/async-util';
+import {noAwait, waitUntil} from '../../../utils/async-util';
 import {
   AutocompleteCache,
   AutocompletionContext,
@@ -254,6 +254,10 @@
     this.comment?.fix_suggestions?.[0];
 
   @state()
+  previewedGeneratedFixSuggestion: FixSuggestionInfo | undefined =
+    this.comment?.fix_suggestions?.[0];
+
+  @state()
   generatedSuggestionId?: string;
 
   @state()
@@ -1184,6 +1188,18 @@
     }
   }
 
+  // visible for testing
+  async waitPreviewForGeneratedSuggestion() {
+    const generatedFixSuggestion = this.generatedFixSuggestion;
+    if (!generatedFixSuggestion) return;
+    await waitUntil(
+      () =>
+        !!this.suggestionDiffPreview?.previewed &&
+        this.suggestionDiffPreview?.previewLoadedFor === generatedFixSuggestion
+    );
+    this.previewedGeneratedFixSuggestion = generatedFixSuggestion;
+  }
+
   private renderGenerateSuggestEditButton() {
     if (!this.showGeneratedSuggestion()) {
       return nothing;
@@ -1322,6 +1338,7 @@
       return;
     }
     this.generatedFixSuggestion = suggestion;
+    noAwait(this.waitPreviewForGeneratedSuggestion());
 
     try {
       await waitUntil(() => this.getFixSuggestions() !== undefined);
@@ -1558,6 +1575,9 @@
     assert(isDraft(this.comment), 'only drafts are editable');
     if (this.editing) return;
     this.editing = true;
+    // For quickly opening and closing the comment, the suggestion diff preview
+    // might not have time to load and preview.
+    noAwait(this.waitPreviewForGeneratedSuggestion());
   }
 
   // TODO: Move this out of gr-comment. gr-comment should not have a comments
@@ -1841,13 +1861,9 @@
     // Disable fix suggestions when the comment already has a user suggestion
     if (this.comment && hasUserSuggestion(this.comment)) return undefined;
     // we ignore fixSuggestions until they are previewed.
-    if (
-      this.suggestionDiffPreview &&
-      !this.suggestionDiffPreview?.previewed &&
-      !this.suggestionLoading
-    )
-      return undefined;
-    return [this.generatedFixSuggestion];
+    if (this.previewedGeneratedFixSuggestion)
+      return [this.previewedGeneratedFixSuggestion];
+    return undefined;
   }
 
   private handleToggleResolved() {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 5273439..7292c64 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -1158,6 +1158,9 @@
           '#suggestionDiffPreview'
         );
         suggestionDiffPreview.previewed = true;
+        suggestionDiffPreview.previewLoadedFor = generatedFixSuggestion;
+        await element.updateComplete;
+        await element.waitPreviewForGeneratedSuggestion();
         await element.updateComplete;
         element.save();
         await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index dbb89db..fe96d56 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -15,6 +15,7 @@
 import {GrDropdownList} from '../gr-dropdown-list/gr-dropdown-list';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
+  EmailAddress,
   NumericChangeId,
   RepoName,
   RevisionPatchSetNum,
@@ -23,11 +24,11 @@
 
 const emails = [
   {
-    email: 'primary@example.com',
+    email: 'primary@example.com' as EmailAddress,
     preferred: true,
   },
   {
-    email: 'secondary@example.com',
+    email: 'secondary@example.com' as EmailAddress,
     preferred: false,
   },
 ];
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
index ca7fe9c..36266a0 100644
--- a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -7,7 +7,7 @@
 import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, state, query, property} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
 import {getDocUrl} from '../../../utils/url-util';
@@ -16,7 +16,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 import {changeModelToken} from '../../../models/change/change-model';
-import {Comment, PatchSetNumber} from '../../../types/common';
+import {Comment, NumericChangeId, PatchSetNumber} from '../../../types/common';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
 import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
 import {SuggestionsProvider} from '../../../api/suggestions';
@@ -25,6 +25,7 @@
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {getAppContext} from '../../../services/app-context';
 import {Interaction} from '../../../constants/reporting';
+import {isFileUnchanged} from '../../../utils/diff-util';
 
 export const COLLAPSE_SUGGESTION_STORAGE_KEY = 'collapseSuggestionStorageKey';
 
@@ -47,11 +48,15 @@
 
   @state() latestPatchNum?: PatchSetNumber;
 
+  @state() changeNum?: NumericChangeId;
+
   @state()
   suggestionsProvider?: SuggestionsProvider;
 
   @state() private isOwner = false;
 
+  @state() private enableApplyOnUnModifiedFile = false;
+
   /**
    * This is just a reflected property such that css rules can be based on it.
    */
@@ -68,6 +73,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly restApiService = getAppContext().restApiService;
+
   constructor() {
     super();
     subscribe(
@@ -85,6 +92,17 @@
       () => this.getChangeModel().isOwner$,
       x => (this.isOwner = x)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+  }
+
+  override updated(changed: PropertyValues) {
+    if (changed.has('changeNum') || changed.has('latestPatchNum')) {
+      this.checkIfcanEnableApplyOnUnModifiedFile();
+    }
   }
 
   override connectedCallback() {
@@ -277,7 +295,9 @@
     if (!this.comment?.fix_suggestions) return;
     this.applyingFix = true;
     try {
-      await this.suggestionDiffPreview?.applyFixSuggestion();
+      await this.suggestionDiffPreview?.applyFixSuggestion(
+        this.enableApplyOnUnModifiedFile
+      );
     } finally {
       this.applyingFix = false;
     }
@@ -285,6 +305,7 @@
 
   private isApplyEditDisabled() {
     if (this.comment?.patch_set === undefined) return true;
+    if (this.enableApplyOnUnModifiedFile) return false;
     return this.comment.patch_set !== this.latestPatchNum;
   }
 
@@ -294,6 +315,29 @@
       ? 'You cannot apply this fix because it is from a previous patchset'
       : '';
   }
+
+  private async checkIfcanEnableApplyOnUnModifiedFile() {
+    // if enabled we don't need to enable
+    if (!this.isApplyEditDisabled()) return;
+
+    const basePatchNum = this.comment?.patch_set;
+    const path = this.comment?.path;
+
+    if (!basePatchNum || !this.latestPatchNum || !path || !this.changeNum) {
+      return;
+    }
+
+    const diff = await this.restApiService.getDiff(
+      this.changeNum,
+      basePatchNum,
+      this.latestPatchNum,
+      path
+    );
+
+    if (diff && isFileUnchanged(diff)) {
+      this.enableApplyOnUnModifiedFile = true;
+    }
+  }
 }
 
 declare global {
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 d67b842..900e9ed 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
@@ -307,11 +307,14 @@
 
   private convertCodeToSuggestions() {
     const marks = this.renderRoot.querySelectorAll('mark');
-    for (const userSuggestionMark of marks) {
+    marks.forEach((userSuggestionMark, index) => {
       const userSuggestion = document.createElement('gr-user-suggestion-fix');
       // Temporary workaround for bug - tabs replacement
       if (this.content.includes('\t')) {
-        userSuggestion.textContent = getUserSuggestionFromString(this.content);
+        userSuggestion.textContent = getUserSuggestionFromString(
+          this.content,
+          index
+        );
       } else {
         userSuggestion.textContent = userSuggestionMark.textContent ?? '';
       }
@@ -319,7 +322,7 @@
         userSuggestion,
         userSuggestionMark
       );
-    }
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 2da1fb8..310c360 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -355,12 +355,17 @@
     try {
       resp = await this.fetchImpl(fetchReq);
     } catch (err) {
+      // Wrap the error to get more information about the stack.
+      const newErr = new Error(
+        `Network error when trying to fetch. Cause: ${(err as Error).message}`
+      );
+      newErr.stack = (newErr.stack ?? '') + '\n' + ((err as Error).stack ?? '');
       if (req.errFn) {
-        await req.errFn.call(undefined, null, err as Error);
+        await req.errFn.call(undefined, null, newErr);
       } else {
-        fireNetworkError(err as Error);
+        fireNetworkError(newErr);
       }
-      throw err;
+      throw newErr;
     }
     if (req.reportServerError && !resp.ok) {
       if (req.errFn) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 0f94a4b..0f7acae 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -206,7 +206,10 @@
         const promise = helper.fetchJSON({url: '/dummy/url'});
         await assertReadRequest();
         const err = await assertFails(promise);
-        assert.equal((err as Error).message, 'No response');
+        assert.equal(
+          (err as Error).message,
+          'Network error when trying to fetch. Cause: No response'
+        );
         await waitEventLoop();
         assert.isTrue(networkErrorCalled);
         assert.isFalse(serverErrorCalled);
@@ -221,7 +224,10 @@
         });
         await assertReadRequest();
         const err = await assertFails(promise);
-        assert.equal((err as Error).message, 'No response');
+        assert.equal(
+          (err as Error).message,
+          'Network error when trying to fetch. Cause: No response'
+        );
         await waitEventLoop();
         assert.isTrue(errFn.called);
         assert.isFalse(networkErrorCalled);
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 504ee63..d059c7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -7,7 +7,13 @@
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {getAppContext} from '../../../services/app-context';
-import {Comment, EDIT, BasePatchSetNum, RepoName} from '../../../types/common';
+import {
+  Comment,
+  EDIT,
+  BasePatchSetNum,
+  PatchSetNumber,
+  RepoName,
+} from '../../../types/common';
 import {anyLineTooLong} from '../../../utils/diff-util';
 import {
   DiffLayer,
@@ -79,6 +85,8 @@
   @state()
   diffPrefs?: DiffPreferencesInfo;
 
+  @state() latestPatchNum?: PatchSetNumber;
+
   @state()
   renderPrefs: RenderPreferences = {
     disable_context_control_buttons: true,
@@ -120,6 +128,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
       () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         if (!diffPreferences) return;
@@ -285,6 +298,7 @@
     });
     if (currentPreviews.length > 0) {
       this.preview = currentPreviews[0];
+      this.previewed = true;
       this.previewLoadedFor = this.fixSuggestionInfo;
     }
 
@@ -299,9 +313,9 @@
    * Similar code flow is in gr-apply-fix-dialog.handleApplyFix
    * Used in gr-fix-suggestions
    */
-  public applyFixSuggestion() {
+  public applyFixSuggestion(onLatestPatchset = false) {
     if (this.suggestion || !this.fixSuggestionInfo) return;
-    this.applyFix(this.fixSuggestionInfo);
+    return this.applyFix(this.fixSuggestionInfo, onLatestPatchset);
   }
 
   /**
@@ -323,7 +337,10 @@
     this.applyFix(fixSuggestions[0]);
   }
 
-  private async applyFix(fixSuggestion: FixSuggestionInfo) {
+  private async applyFix(
+    fixSuggestion: FixSuggestionInfo,
+    onLatestPatchset = false
+  ) {
     const changeNum = this.changeNum;
     const basePatchNum = this.comment?.patch_set as BasePatchSetNum;
     if (!changeNum || !basePatchNum || !fixSuggestion) return;
@@ -331,7 +348,7 @@
     this.reporting.time(Timing.APPLY_FIX_LOAD);
     const res = await this.restApiService.applyFixSuggestion(
       changeNum,
-      basePatchNum,
+      onLatestPatchset ? this.latestPatchNum ?? basePatchNum : basePatchNum,
       fixSuggestion.replacements
     );
     this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
@@ -352,6 +369,7 @@
           forceReload: !this.hasEdit,
         })
       );
+      fire(this, 'reload-diff', {path: this.comment?.path});
       fire(this, 'apply-user-suggestion', {});
     }
   }
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 51bfcd9..5eb9cac 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -54,6 +54,8 @@
       return {name: 'code'};
     case LinkIcon.FILE_PRESENT:
       return {name: 'file_present'};
+    case LinkIcon.VIEW_TIMELINE:
+      return {name: 'view_timeline'};
     default:
       // We don't throw an assertion error here, because plugins don't have to
       // be written in TypeScript, so we may encounter arbitrary strings for
@@ -145,7 +147,9 @@
   if (replacements.length === 0) return undefined;
 
   return {
-    description: fix.description || `Fix provided by ${checkName}`,
+    description: [fix.description, `Fix provided by ${checkName}`]
+      .filter(Boolean)
+      .join(' - '),
     fix_id: PROVIDED_FIX_ID,
     replacements,
   };
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 02b5796..164858d 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -59,10 +59,22 @@
   },
 };
 
+export const SERVER_INFO_ROUTE: Route<AdminViewState> = {
+  urlPattern: /^\/admin\/server-info$/,
+  createState: () => {
+    const state: AdminViewState = {
+      view: GerritView.ADMIN,
+      adminView: AdminChildView.SERVER_INFO,
+    };
+    return state;
+  },
+};
+
 export enum AdminChildView {
   REPOS = 'gr-repo-list',
   GROUPS = 'gr-admin-group-list',
   PLUGINS = 'gr-plugin-list',
+  SERVER_INFO = 'gr-server-info',
 }
 const ADMIN_LINKS: NavLink[] = [
   {
@@ -84,6 +96,12 @@
     url: createAdminUrl({adminView: AdminChildView.PLUGINS}),
     view: 'gr-plugin-list' as GerritView,
   },
+  {
+    name: 'Server Info',
+    section: 'Server Info',
+    url: createAdminUrl({adminView: AdminChildView.SERVER_INFO}),
+    view: 'gr-server-info' as GerritView,
+  },
 ];
 
 export interface AdminLink {
@@ -277,6 +295,8 @@
       return `${getBaseUrl()}/admin/groups`;
     case AdminChildView.PLUGINS:
       return `${getBaseUrl()}/admin/plugins`;
+    case AdminChildView.SERVER_INFO:
+      return `${getBaseUrl()}/admin/server-info`;
   }
 }
 
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index 5d142bf..1cd1897 100644
--- a/polygerrit-ui/app/models/views/admin_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -55,6 +55,11 @@
       assert.isNotOk(res.links[2].subsection);
     }
 
+    if (expected.serverInfoShown) {
+      assert.equal(res.links[3].name, 'Server Info');
+      assert.isNotOk(res.links[3].subsection);
+    }
+
     if (expected.projectPageShown) {
       assert.isOk(res.links[0].subsection);
       assert.equal(res.links[0].subsection!.children!.length, 6);
@@ -116,6 +121,7 @@
         groupListShown: false,
         groupPageShown: false,
         pluginListShown: false,
+        serverInfoShown: false,
       };
     });
 
@@ -162,7 +168,7 @@
 
     setup(() => {
       expected = {
-        totalLength: 2,
+        totalLength: 3,
         pluginListShown: false,
       };
       capabilityStub.returns(Promise.resolve({}));
@@ -203,9 +209,10 @@
     setup(() => {
       capabilityStub.returns(Promise.resolve({viewPlugins: true}));
       expected = {
-        totalLength: 3,
+        totalLength: 4,
         groupListShown: true,
         pluginListShown: true,
+        serverInfoShown: true,
       };
     });
 
@@ -312,7 +319,7 @@
       ];
       menuLinkStub.returns(generatedLinks);
       expected = Object.assign(expected, {
-        totalLength: 4,
+        totalLength: 5,
         pluginGeneratedLinks: generatedLinks,
       });
       await testAdminLinks(account, options, expected);
@@ -339,7 +346,7 @@
       ];
       menuLinkStub.returns(generatedLinks);
       expected = Object.assign(expected, {
-        totalLength: 3,
+        totalLength: 4,
         pluginGeneratedLinks: [generatedLinks[0]],
       });
       await testAdminLinks(account, options, expected);
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 05927f3..09ba661 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -18,6 +18,7 @@
   PLUGIN_SCREEN = 'plugin-screen',
   REPO = 'repo',
   SEARCH = 'search',
+  SERVER_INFO = 'server-info',
   SETTINGS = 'settings',
 }
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index e12e1a8..ae684bf 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -60,6 +60,7 @@
   EDIT,
   EditPatchSet,
   EmailAddress,
+  EmailInfo,
   FetchInfo,
   FileInfo,
   GerritInfo,
@@ -81,6 +82,7 @@
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
   MaxObjectSizeLimitInfo,
+  MetadataInfo,
   NumericChangeId,
   ParentCommitInfo,
   PARENT,
@@ -163,6 +165,7 @@
   DownloadSchemeInfo,
   EditPatchSet,
   EmailAddress,
+  EmailInfo,
   FileInfo,
   FixId,
   FixSuggestionInfo,
@@ -185,6 +188,7 @@
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
   MaxObjectSizeLimitInfo,
+  MetadataInfo,
   NumericChangeId,
   ParentCommitInfo,
   PatchRange,
@@ -381,17 +385,7 @@
   capabilities?: AccountCapabilityInfo;
   groups: GroupInfo[];
   external_ids: AccountExternalIdInfo[];
-  metadata: AccountMetadataInfo[];
-}
-
-/**
- * The `AccountMetadataInfo` entity contains account metadata.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-metadata-info
- */
-export interface AccountMetadataInfo {
-  name: string;
-  value?: string;
-  description?: string;
+  metadata: MetadataInfo[];
 }
 
 /**
@@ -1020,16 +1014,6 @@
 }
 
 /**
- * The EmailInfo entity contains information about an email address of a user
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#email-info
- */
-export interface EmailInfo {
-  email: string;
-  preferred?: boolean;
-  pending_confirmation?: boolean;
-}
-
-/**
  * The CapabilityInfo entity contains information about the global capabilities of a user
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#capability-info
  */
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index c8e5173..1324061 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -556,12 +556,17 @@
   return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
 }
 
-export function getUserSuggestionFromString(content: string) {
-  const start =
-    content.indexOf(USER_SUGGESTION_START_PATTERN) +
-    USER_SUGGESTION_START_PATTERN.length;
-  const end = content.indexOf('\n```', start);
-  return content.substring(start, end);
+export function getUserSuggestionFromString(
+  content: string,
+  suggestionIndex = 0
+) {
+  const suggestions = content.split(USER_SUGGESTION_START_PATTERN).slice(1);
+  if (suggestions.length === 0) return '';
+
+  const targetIndex = Math.min(suggestionIndex, suggestions.length - 1);
+  const targetSuggestion = suggestions[targetIndex];
+  const end = targetSuggestion.indexOf('\n```');
+  return end !== -1 ? targetSuggestion.substring(0, end) : targetSuggestion;
 }
 
 export function getUserSuggestion(comment: Comment) {
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 16c8bfd..031a738 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -18,6 +18,7 @@
   getMentionedThreads,
   isNewThread,
   createNew,
+  getUserSuggestionFromString,
 } from './comment-util';
 import {
   createAccountWithEmail,
@@ -533,4 +534,69 @@
       ]);
     });
   });
+
+  suite('getUserSuggestionFromString', () => {
+    const createSuggestionContent = (suggestions: string[]) =>
+      suggestions
+        .map(s => `${USER_SUGGESTION_START_PATTERN}${s}\n\`\`\``)
+        .join('\n');
+
+    test('returns empty string for content without suggestions', () => {
+      const content = 'This is a comment without any suggestions.';
+      assert.equal(getUserSuggestionFromString(content), '');
+    });
+
+    test('returns first suggestion when no index is provided', () => {
+      const content = createSuggestionContent(['First suggestion']);
+      assert.equal(getUserSuggestionFromString(content), 'First suggestion');
+    });
+
+    test('returns correct suggestion for given index', () => {
+      const content = createSuggestionContent([
+        'First suggestion',
+        'Second suggestion',
+        'Third suggestion',
+      ]);
+      assert.equal(
+        getUserSuggestionFromString(content, 1),
+        'Second suggestion'
+      );
+    });
+
+    test('returns last suggestion when index is out of bounds', () => {
+      const content = createSuggestionContent([
+        'First suggestion',
+        'Second suggestion',
+      ]);
+      assert.equal(
+        getUserSuggestionFromString(content, 5),
+        'Second suggestion'
+      );
+    });
+
+    test('handles suggestion without closing backticks', () => {
+      const content = `${USER_SUGGESTION_START_PATTERN}Unclosed suggestion`;
+      assert.equal(getUserSuggestionFromString(content), 'Unclosed suggestion');
+    });
+
+    test('handles multiple suggestions with varying content', () => {
+      const content = createSuggestionContent([
+        'First\nMultiline\nSuggestion',
+        'Second suggestion',
+        'Third suggestion with `backticks`',
+      ]);
+      assert.equal(
+        getUserSuggestionFromString(content, 0),
+        'First\nMultiline\nSuggestion'
+      );
+      assert.equal(
+        getUserSuggestionFromString(content, 1),
+        'Second suggestion'
+      );
+      assert.equal(
+        getUserSuggestionFromString(content, 2),
+        'Third suggestion with `backticks`'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/submit-requirement-util.ts b/polygerrit-ui/app/utils/submit-requirement-util.ts
index ff99b7f..2eec672 100644
--- a/polygerrit-ui/app/utils/submit-requirement-util.ts
+++ b/polygerrit-ui/app/utils/submit-requirement-util.ts
@@ -36,12 +36,17 @@
       break;
     }
     searchStartIndex = index + match.length;
-    // Include unary minus.
+    // Check for unary minus *before* adding the match
+    let atomIsPassing = isPassing; // Use a local variable
     if (index !== 0 && text[index - 1] === '-') {
       --index;
-      isPassing = !isPassing;
+      atomIsPassing = !isPassing; // Negate only for this occurrence
     }
-    matchedAtoms.push({start: index, end: searchStartIndex, isPassing});
+    matchedAtoms.push({
+      start: index,
+      end: searchStartIndex,
+      isPassing: atomIsPassing,
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/utils/submit-requirement-util_test.ts b/polygerrit-ui/app/utils/submit-requirement-util_test.ts
index a35a121..7982987 100644
--- a/polygerrit-ui/app/utils/submit-requirement-util_test.ts
+++ b/polygerrit-ui/app/utils/submit-requirement-util_test.ts
@@ -113,4 +113,42 @@
       },
     ]);
   });
+
+  test('atomizeExpression b/370742469', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression:
+        '-is:android-cherry-pick_exemptedusers OR is:android-cherry-pick_exemptedusers',
+      passing_atoms: [
+        'is:android-cherry-pick_exemptedusers',
+        'is:android-cherry-pick_exemptedusers',
+        'project:platform/frameworks/support',
+      ],
+      failing_atoms: [
+        'label:Code-Review=MIN',
+        'label:Code-Review=MAX,user=non_uploader',
+        'label:Code-Review=MAX,count>=2',
+        'label:Code-Review=MAX',
+        'label:Exempt=+1',
+        'uploader:1474732',
+        'project:platform/developers/docs',
+      ],
+    };
+
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+        isAtom: true,
+        value: '-is:android-cherry-pick_exemptedusers',
+      },
+      {
+        value: ' OR ',
+        isAtom: false,
+      },
+      {
+        atomStatus: SubmitRequirementExpressionAtomStatus.PASSING,
+        isAtom: true,
+        value: 'is:android-cherry-pick_exemptedusers',
+      },
+    ]);
+  });
 });
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 91caf31..39697be 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -137,18 +137,18 @@
         sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.12.0"
+    SSHD_VERS = "2.14.0"
 
     maven_jar(
         name = "sshd-osgi",
         artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
-        sha1 = "32b8de1cbb722ba75bdf9898e0c41d42af00ce57",
+        sha1 = "6ef66228a088f8ac1383b2ff28f3102f80ebc01a",
     )
 
     maven_jar(
         name = "sshd-sftp",
         artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS,
-        sha1 = "0f96f00a07b186ea62838a6a4122e8f4cad44df6",
+        sha1 = "c070ac920e72023ae9ab0a3f3a866bece284b470",
     )
 
     maven_jar(
@@ -166,7 +166,7 @@
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "8b202f7d4c0d7b714fd0c93a1352af52aa031149",
+        sha1 = "05e1293af53a196ac3c5a4b01dd88985e8672e9e",
     )
 
     maven_jar(