Merge "Surface LockFailureException as 503 Service Unavailable"
diff --git a/.mailmap b/.mailmap
index bd4d222..f5f8f3e 100644
--- a/.mailmap
+++ b/.mailmap
@@ -11,6 +11,7 @@
 Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
 Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
+Dave Borowitz <dborowitz@google.com>                                                        <dborowitz@google.com>
 David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
 David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
 David Pursehouse <dpursehouse@collab.net>                                                   <david.pursehouse@sonymobile.com>
@@ -43,6 +44,7 @@
 Mark Derricutt <mark.derricutt@smxemail.com>                                                <mark@talios.com>
 Martin Fick <mfick@codeaurora.org>                                                          <mogulguy10@gmail.com>
 Martin Fick <mfick@codeaurora.org>                                                          <mogulguy@yahoo.com>
+Maxime Guerreiro <maximeg@google.com>                                                       <maximeg@google.com>
 Michael Zhou <moz@google.com>                                                               <zhoumotongxue008@gmail.com>
 Mônica Dionísio <monica.dionisio@sonyericsson.com>                                          monica.dionisio <monica.dionisio@sonyericsson.com>
 Nasser Grainawi <nasser@grainawi.org>                                                       <nasser@codeaurora.org>
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 189c6c7..e6a6227 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2731,10 +2731,114 @@
   @Override
   public String intercept(String in) {
     return pluginName + " mycommand";
+----
+
+
+[[pre-submit-evaluator]]
+== Pre-submit Validation Plugins
+
+Gerrit provides an extension point that enables plugins to prevent a change
+from being submitted.
+
+[IMPORTANT]
+This extension point **must NOT** be used for long or slow operations, like
+calling external programs or content, running unit tests...
+Slow operations will hurt the whole Gerrit instance.
+
+This can be used to implement custom rules that changes have to match to become
+submittable. A more concrete example: the Prolog rules engine can be
+implemented using this.
+
+Gerrit calls the plugins once per change and caches the results. Although it is
+possible to predict when this interface will be triggered, this should not be
+considered as a feature. Plugins should only rely on the internal state of the
+ChangeData, not on external values like date and time, remote content or
+randomness.
+
+Plugins are expected to support rules inheritance themselves, providing ways
+to configure it and handling the logic behind it.
+Please note that no inheritance is sometimes better than badly handled
+inheritance: mis-communication and strange behaviors caused by inheritance
+may and will confuse the users. Each plugins is responsible for handling the
+project hierarchy and taking wise actions. Gerrit does not enforce it.
+
+Once Gerrit has gathered every plugins' SubmitRecords, it stores them.
+
+Plugins accept or reject a given change using `SubmitRecord.Status`.
+If a change is ready to be submitted, `OK`. If it is not ready and requires
+modifications, `NOT_READY`. Other statuses are available for particular cases.
+A change can be submitted if all the plugins accept the change.
+
+Plugins may also decide not to vote on a given change by returning an empty
+Collection (ie: the plugin is not enabled for this repository), or to vote
+several times (ie: one SubmitRecord per project in the hierarchy).
+The results are handled as if multiple plugins voted for the change.
+
+If a plugin decides not to vote, it's name will not be displayed in the UI and
+it will not be recoded in the database.
+
+.Gerrit's Pre-submit handling with three plugins
+[width="50%",cols="^m,^m,^m,^m",frame="topbot",options="header"]
+|=======================================================
+|  Plugin A  |  Plugin B  |  Plugin C  | Final decision
+|     OK     |     OK     |     OK     | OK
+|     OK     |     OK     |     /      | OK
+|     OK     |     OK     | RULE_ERROR | NOT_READY
+|     OK     | NOT_READY  |     OK     | NOT_READY
+|  NOT_READY |     OK     |     OK     | NOT_READY
+|=======================================================
+
+
+This makes composing plugins really easy.
+
+- If a plugin places a veto on a change, it can't be submitted.
+- If a plugin isn't enabled for a project (or isn't needed for this change),
+  it returns an empty collection.
+- If all the plugins answer `OK`, the change can be submitted.
+
+
+A more rare case, but worth documenting: if there are no installed plugins,
+the labels will be compared to the rules defined in the project's config,
+and the permission system will be used to allow or deny a submit request.
+
+Some rules are defined internally to provide a common base ground (and sanity):
+changes that are marked as WIP or that are closed (abandoned, merged) can't be merged.
+
+
+[source, java]
+----
+import java.util.Collection;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRecord.Status;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+
+public class MyPluginRules implements SubmitRule {
+  public Collection<SubmitRecord> evaluate(ChangeData changeData) {
+    // Implement your submitability logic here
+
+    // Assuming we want to prevent this change from being submitted:
+    SubmitRecord record;
+    record.status = Status.NOT_READY;
+    return record;
   }
 }
 ----
 
+Don't forget to register your class!
+
+[source, java]
+----
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.inject.AbstractModule;
+
+public class MyPluginModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(SubmitRule.class).annotatedWith(Exports.named("myPlugin")).to(MyPluginRules.class);
+  }
+}
+----
 
 == SEE ALSO
 
diff --git a/Documentation/json.txt b/Documentation/json.txt
index f1dbe73..2b34185 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -178,6 +178,21 @@
 labels:: This describes the state of each code review
 <<label,label attribute>>, unless the status is RULE_ERROR.
 
+requirements:: Each <<requirement>> describes what needs to be changed
+in order for the change to be submittable.
+
+
+[[requirement]]
+== requirement
+Information about a requirement (not met) in order to submit a change.
+
+shortReason:: A short description of the requirement (a hint).
+
+fullReason:: A longer and descriptive message explaining what needs to
+be changed to meet the requirement.
+
+label:: (Optional) The name of the linked label, if set by a pre-submit rule.
+
 [[label]]
 == label
 Information about a code review label for a change.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index c1ca595..70c00ff 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6653,12 +6653,20 @@
 The `RevertInput` entity contains information for reverting a change.
 
 [options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
-|`message`     |optional|
+|=============================
+|Field Name      ||Description
+|`message`       |optional|
 Message to be added as review comment to the change when reverting the
 change.
-|===========================
+|`notify`        |optional|
+Notify handling that defines to whom email notifications should be sent
+for reverting the change. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
+|`notify_details`|optional|
+Additional information about whom to notify about the revert as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|=============================
 
 [[review-info]]
 === ReviewInfo
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
old mode 100644
new mode 100755
index 0e3dffe..99d9a49
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -17,34 +17,24 @@
 This script will populate an empty standard Gerrit instance with some
 data for local testing.
 
-This script requires 'requests'. If you do not have this module, run
-'pip3 install requests' to install it.
-
 TODO(hiesel): Make real git commits instead of empty changes
 TODO(hiesel): Add comments
 """
 
 import atexit
 import json
+import optparse
 import os
 import random
 import shutil
 import subprocess
 import tempfile
-
 import requests
 import requests.auth
 
 DEFAULT_TMP_PATH = "/tmp"
 TMP_PATH = ""
-BASE_URL = "http://localhost:8080/a/"
-ACCESS_URL = BASE_URL + "access/"
-ACCOUNTS_URL = BASE_URL + "accounts/"
-CHANGES_URL = BASE_URL + "changes/"
-CONFIG_URL = BASE_URL + "config/"
-GROUPS_URL = BASE_URL + "groups/"
-PLUGINS_URL = BASE_URL + "plugins/"
-PROJECTS_URL = BASE_URL + "projects/"
+BASE_URL = "http://localhost:%d/a/"
 
 ADMIN_BASIC_AUTH = requests.auth.HTTPBasicAuth("admin", "secret")
 
@@ -158,7 +148,7 @@
 def fetch_admin_group():
   global GROUP_ADMIN
   # Get admin group
-  r = json.loads(clean(requests.get(GROUPS_URL + "?suggest=ad&p=All-Projects",
+  r = json.loads(clean(requests.get(BASE_URL + "groups/" + "?suggest=ad&p=All-Projects",
                                     headers=HEADERS,
                                     auth=ADMIN_BASIC_AUTH).text))
   admin_group_name = r.keys()[0]
@@ -222,7 +212,7 @@
      "visible_to_all": False, "owner": GROUP_ADMIN["name"],
      "owner_id": GROUP_ADMIN["id"]}]
   for g in groups:
-    requests.put(GROUPS_URL + g["name"],
+    requests.put(BASE_URL + "groups/" + g["name"],
                  json.dumps(g),
                  headers=HEADERS,
                  auth=ADMIN_BASIC_AUTH)
@@ -244,7 +234,7 @@
      "branches": ["master"], "description": "some small scripts.",
      "owners": [owner_groups[3]], "create_empty_commit": True}]
   for p in projects:
-    requests.put(PROJECTS_URL + p["name"],
+    requests.put(BASE_URL + "projects/" + p["name"],
                  json.dumps(p),
                  headers=HEADERS,
                  auth=ADMIN_BASIC_AUTH)
@@ -253,7 +243,7 @@
 
 def create_gerrit_users(gerrit_users):
   for user in gerrit_users:
-    requests.put(ACCOUNTS_URL + user["username"],
+    requests.put(BASE_URL + "accounts/" + user["username"],
                  json.dumps(user),
                  headers=HEADERS,
                  auth=ADMIN_BASIC_AUTH)
@@ -267,7 +257,7 @@
     "branch": "master",
     "status": "NEW",
   }
-  requests.post(CHANGES_URL,
+  requests.post(BASE_URL + "changes/",
                 json.dumps(change),
                 headers=HEADERS,
                 auth=basic_auth(user))
@@ -278,8 +268,22 @@
 
 
 def main():
+  p = optparse.OptionParser()
+  p.add_option("-u", "--user_count", action="store",
+               default=100,
+               type='int',
+               help="number of users to generate")
+  p.add_option("-p", "--port", action="store",
+               default=8080,
+               type='int',
+               help="port of server")
+  (options, _) = p.parse_args()
+  global BASE_URL
+  BASE_URL = BASE_URL % options.port
+  print BASE_URL
+
   set_up()
-  gerrit_users = get_random_users(100)
+  gerrit_users = get_random_users(options.user_count)
 
   group_names = create_gerrit_groups()
   for idx, u in enumerate(gerrit_users):
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 06efbfd..09ee8f0 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -201,7 +201,12 @@
                 firstTest = description;
               }
               beforeTest(description);
-              try (ProjectResetter resetter = resetProjects(projectResetter.builder())) {
+              ProjectResetter.Config input = resetProjects();
+              if (input == null) {
+                input = defaultResetProjects();
+              }
+
+              try (ProjectResetter resetter = projectResetter.builder().build(input)) {
                 AbstractDaemonTest.this.resetter = resetter;
                 base.evaluate();
               } finally {
@@ -317,8 +322,12 @@
   }
 
   /** Controls which project and branches should be reset after each test case. */
-  protected ProjectResetter resetProjects(ProjectResetter.Builder resetter) throws IOException {
-    return resetter
+  protected ProjectResetter.Config resetProjects() {
+    return null;
+  }
+
+  private ProjectResetter.Config defaultResetProjects() {
+    return new ProjectResetter.Config()
         // Don't reset all refs so that refs/sequences/changes is not touched and change IDs are
         // not reused.
         .reset(allProjects, RefNames.REFS_CONFIG)
@@ -331,8 +340,7 @@
             RefNames.REFS_USERS + "*",
             RefNames.REFS_EXTERNAL_IDS,
             RefNames.REFS_STARRED_CHANGES + "*",
-            RefNames.REFS_DRAFT_COMMENTS + "*")
-        .build();
+            RefNames.REFS_DRAFT_COMMENTS + "*");
   }
 
   protected void restartAsSlave() throws Exception {
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 086cacb..36f9b82 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -25,7 +25,6 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
 import com.google.common.truth.Truth;
-import com.google.gerrit.acceptance.ProjectResetter.Builder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -63,10 +62,10 @@
   }
 
   @Override
-  protected ProjectResetter resetProjects(Builder resetter) throws IOException {
+  protected ProjectResetter.Config resetProjects() {
     // Don't reset anything so that stagedUsers can be cached across all tests.
     // Without this caching these tests become much too slow.
-    return resetter.build();
+    return new ProjectResetter.Config();
   }
 
   protected static FakeEmailSenderSubject assertThat(FakeEmailSender sender) {
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index 569a0c1..0e4ea19 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -88,8 +88,6 @@
     @Nullable private final AccountCache accountCache;
     @Nullable private final ProjectCache projectCache;
 
-    private final Multimap<Project.NameKey, String> refsByProject;
-
     @Inject
     public Builder(
         GitRepositoryManager repoManager,
@@ -102,10 +100,27 @@
       this.accountCreator = accountCreator;
       this.accountCache = accountCache;
       this.projectCache = projectCache;
+    }
+
+    public ProjectResetter build(ProjectResetter.Config input) throws IOException {
+      return new ProjectResetter(
+          repoManager,
+          allUsersName,
+          accountCreator,
+          accountCache,
+          projectCache,
+          input.refsByProject);
+    }
+  }
+
+  public static class Config {
+    private final Multimap<Project.NameKey, String> refsByProject;
+
+    public Config() {
       this.refsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
     }
 
-    public Builder reset(Project.NameKey project, String... refPatterns) {
+    public Config reset(Project.NameKey project, String... refPatterns) {
       List<String> refPatternList = Arrays.asList(refPatterns);
       if (refPatternList.isEmpty()) {
         refPatternList = ImmutableList.of(RefNames.REFS + "*");
@@ -113,11 +128,6 @@
       refsByProject.putAll(project, refPatternList);
       return this;
     }
-
-    public ProjectResetter build() throws IOException {
-      return new ProjectResetter(
-          repoManager, allUsersName, accountCreator, accountCache, projectCache, refsByProject);
-    }
   }
 
   @Inject private GitRepositoryManager repoManager;
diff --git a/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
index 9151222..3cd9c43 100644
--- a/java/com/google/gerrit/common/data/SubmitRecord.java
+++ b/java/com/google/gerrit/common/data/SubmitRecord.java
@@ -18,15 +18,20 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
-import java.util.Optional;
 
-/** Describes the state required to submit a change. */
+/** Describes the state and edits required to submit a change. */
 public class SubmitRecord {
-  public static Optional<SubmitRecord> findOkRecord(Collection<SubmitRecord> in) {
-    if (in == null) {
-      return Optional.empty();
+  public static boolean allRecordsOK(Collection<SubmitRecord> in) {
+    if (in == null || in.isEmpty()) {
+      // If the list is null or empty, it means that this Gerrit installation does not
+      // have any form of validation rules.
+      // Hence, the permission system should be used to determine if the change can be merged
+      // or not.
+      return true;
     }
-    return in.stream().filter(r -> r.status == Status.OK).findFirst();
+
+    // The change can be submitted, unless at least one plugin prevents it.
+    return in.stream().noneMatch(r -> r.status != Status.OK);
   }
 
   public enum Status {
@@ -36,7 +41,7 @@
     /** The change is ready for submission. */
     OK,
 
-    /** The change is missing a required label. */
+    /** Something is preventing this change from being submitted. */
     NOT_READY,
 
     /** The change has been closed. */
@@ -55,6 +60,7 @@
 
   public Status status;
   public List<Label> labels;
+  public List<SubmitRequirement> requirements;
   public String errorMessage;
 
   public static class Label {
@@ -140,6 +146,14 @@
         delimiter = ", ";
       }
     }
+    sb.append("],[");
+    if (requirements != null) {
+      String delimiter = "";
+      for (SubmitRequirement requirement : requirements) {
+        sb.append(delimiter).append(requirement);
+        delimiter = ", ";
+      }
+    }
     sb.append(']');
     return sb.toString();
   }
@@ -150,13 +164,14 @@
       SubmitRecord r = (SubmitRecord) o;
       return Objects.equals(status, r.status)
           && Objects.equals(labels, r.labels)
-          && Objects.equals(errorMessage, r.errorMessage);
+          && Objects.equals(errorMessage, r.errorMessage)
+          && Objects.equals(requirements, r.requirements);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(status, labels, errorMessage);
+    return Objects.hash(status, labels, errorMessage, requirements);
   }
 }
diff --git a/java/com/google/gerrit/common/data/SubmitRequirement.java b/java/com/google/gerrit/common/data/SubmitRequirement.java
new file mode 100644
index 0000000..a75f3c0
--- /dev/null
+++ b/java/com/google/gerrit/common/data/SubmitRequirement.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2018 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.common.data;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.common.Nullable;
+import java.util.Objects;
+import java.util.Optional;
+
+/** Describes a requirement to submit a change. */
+public final class SubmitRequirement {
+  private final String shortReason;
+  private final String fullReason;
+  @Nullable private final String label;
+
+  public SubmitRequirement(String shortReason, String fullReason, @Nullable String label) {
+    this.shortReason = requireNonNull(shortReason);
+    this.fullReason = requireNonNull(fullReason);
+    this.label = label;
+  }
+
+  public String shortReason() {
+    return shortReason;
+  }
+
+  public String fullReason() {
+    return fullReason;
+  }
+
+  public Optional<String> label() {
+    return Optional.ofNullable(label);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o instanceof SubmitRequirement) {
+      SubmitRequirement that = (SubmitRequirement) o;
+      return Objects.equals(shortReason, that.shortReason)
+          && Objects.equals(fullReason, that.fullReason)
+          && Objects.equals(label, that.label);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(shortReason, fullReason, label);
+  }
+
+  @Override
+  public String toString() {
+    return "SubmitRequirement{"
+        + "shortReason='"
+        + shortReason
+        + '\''
+        + ", fullReason='"
+        + fullReason
+        + '\''
+        + ", label='"
+        + label
+        + '\''
+        + '}';
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index 893472e..c1be9b0 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -15,7 +15,13 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
 
 public class RevertInput {
   @DefaultInput public String message;
+
+  /** Who to send email notifications to after change is created. */
+  public NotifyHandling notify = NotifyHandling.ALL;
+
+  public Map<RecipientType, NotifyInfo> notifyDetails;
 }
diff --git a/java/com/google/gerrit/metrics/proc/JGitMetricModule.java b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
index c3eb39f..4487ae5 100644
--- a/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
-import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
+import org.eclipse.jgit.storage.file.WindowCacheStats;
 
 public class JGitMetricModule extends MetricModule {
   @Override
@@ -32,7 +32,7 @@
         new Supplier<Long>() {
           @Override
           public Long get() {
-            return WindowCacheStatAccessor.getOpenBytes();
+            return WindowCacheStats.getOpenBytes();
           }
         });
 
@@ -43,7 +43,7 @@
         new Supplier<Integer>() {
           @Override
           public Integer get() {
-            return WindowCacheStatAccessor.getOpenFiles();
+            return WindowCacheStats.getOpenFiles();
           }
         });
   }
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index a7803a9..357674d 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -157,7 +157,6 @@
     install(new ExternalIdModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
-    install(new PrologModule());
     install(AccountCacheImpl.module());
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
@@ -169,7 +168,10 @@
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ProjectState.Factory.class);
+
+    // Submit rule evaluator
     factory(SubmitRuleEvaluator.Factory.class);
+    install(new PrologModule());
 
     bind(ChangeJson.Factory.class).toProvider(Providers.<ChangeJson.Factory>of(null));
     bind(EventUtil.class).toProvider(Providers.<EventUtil>of(null));
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index d86be16..79c57484 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -143,7 +143,21 @@
       try (ReviewDb db = schema.open()) {
         ExternalId id = externalIds.get(who.getExternalIdKey());
         if (id == null) {
+          if (who.getUserName().isPresent()) {
+            ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, who.getUserName().get());
+            ExternalId existingId = externalIds.get(key);
+            if (existingId != null) {
+              // An inconsistency is detected in the database, having a record for scheme "username:"
+              // but no record for scheme "gerrit:". Try to recover by linking
+              // "gerrit:" identity to the existing account.
+              log.warn(
+                  "User {} already has an account; link new identity to the existing account.",
+                  who.getUserName());
+              return link(existingId.accountId(), who);
+            }
+          }
           // New account, automatically create and return.
+          log.info("External ID not found. Attempting to create new account.");
           return create(db, who);
         }
 
@@ -383,6 +397,7 @@
   public AuthResult link(Account.Id to, AuthRequest who)
       throws AccountException, OrmException, IOException, ConfigInvalidException {
     ExternalId extId = externalIds.get(who.getExternalIdKey());
+    log.info("Link another authentication identity to an existing account");
     if (extId != null) {
       if (!extId.accountId().equals(to)) {
         throw new AccountException(
@@ -390,6 +405,7 @@
       }
       update(who, extId);
     } else {
+      log.info("Linking new external ID to the existing account");
       accountsUpdateProvider
           .get()
           .update(
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 5958ed6..4d589a0 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.inject.Inject;
@@ -35,20 +34,17 @@
 
 public class AccountGroupUUIDHandler extends OptionHandler<AccountGroup.UUID> {
   private final GroupBackend groupBackend;
-  private final GroupControl.Factory groupControlFactory;
   private final GroupCache groupCache;
 
   @Inject
   public AccountGroupUUIDHandler(
       final GroupBackend groupBackend,
-      final GroupControl.Factory groupControlFactory,
       @Assisted final CmdLineParser parser,
       @Assisted final OptionDef option,
       @Assisted final Setter<AccountGroup.UUID> setter,
       GroupCache groupCache) {
     super(parser, option, setter);
     this.groupBackend = groupBackend;
-    this.groupControlFactory = groupControlFactory;
     this.groupCache = groupCache;
   }
 
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 113a6bf..19c9666 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -657,7 +657,7 @@
   }
 
   private boolean submittable(ChangeData cd) {
-    return SubmitRecord.findOkRecord(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)).isPresent();
+    return SubmitRecord.allRecordsOK(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT));
   }
 
   private List<SubmitRecord> submitRecords(ChangeData cd) {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 77312be..9536d55 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -177,6 +177,7 @@
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.RulesCache;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -391,6 +392,7 @@
     DynamicSet.setOf(binder(), ActionVisitor.class);
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
+    DynamicSet.setOf(binder(), SubmitRule.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 6310c08..09f2837 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -288,7 +288,32 @@
    */
   public Config getProjectPluginConfigWithInheritance(
       Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
-    return getPluginConfig(projectName, pluginName).getWithInheritance();
+    return getPluginConfig(projectName, pluginName).getWithInheritance(false);
+  }
+
+  /**
+   * Returns the configuration for the specified plugin that is stored in the '{@code
+   * <plugin-name>.config}' file in the 'refs/meta/config' branch of the specified project.
+   * Parameters from the '{@code <plugin-name>.config}' of the parent project are appended to this
+   * project's '{@code <plugin-name>.config}' files.
+   *
+   * <p>E.g.: child project: [mySection "mySubsection"] myKey = childValue
+   *
+   * <p>parent project: [mySection "mySubsection"] myKey = parentValue anotherKey = someValue
+   *
+   * <p>return: [mySection "mySubsection"] myKey = childValue myKey = parentValue anotherKey =
+   * someValue
+   *
+   * @param projectName the name of the project for which the plugin configuration should be
+   *     returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code <plugin-name>.config}' file of the specified
+   *     project with parameters from the parent projects appended to the project values
+   * @throws NoSuchProjectException thrown if the specified project does not exist
+   */
+  public Config getProjectPluginConfigWithMergedInheritance(
+      Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
+    return getPluginConfig(projectName, pluginName).getWithInheritance(true);
   }
 
   /**
@@ -310,7 +335,30 @@
    */
   public Config getProjectPluginConfigWithInheritance(
       ProjectState projectState, String pluginName) {
-    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance();
+    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(false);
+  }
+
+  /**
+   * Returns the configuration for the specified plugin that is stored in the '{@code
+   * <plugin-name>.config}' file in the 'refs/meta/config' branch of the specified project.
+   * Parameters from the '{@code <plugin-name>.config}' of the parent project are appended to this
+   * project's '{@code <plugin-name>.config}' files.
+   *
+   * <p>E.g.: child project: [mySection "mySubsection"] myKey = childValue
+   *
+   * <p>parent project: [mySection "mySubsection"] myKey = parentValue anotherKey = someValue
+   *
+   * <p>return: [mySection "mySubsection"] myKey = childValue myKey = parentValue anotherKey =
+   * someValue
+   *
+   * @param projectState the project for which the plugin configuration should be returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code <plugin-name>.config}' file of the specified
+   *     project with inheriting non-set parameters from the parent projects
+   */
+  public Config getProjectPluginConfigWithMergedInheritance(
+      ProjectState projectState, String pluginName) {
+    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(true);
   }
 
   private ProjectLevelConfig getPluginConfig(Project.NameKey projectName, String pluginName)
diff --git a/java/com/google/gerrit/server/data/SubmitLabelAttribute.java b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
index 1b3c6a48..fec8f7f 100644
--- a/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.data;
 
+/**
+ * Represents a {@link com.google.gerrit.common.data.SubmitRecord.Label} that does not depend on
+ * Gerrit internal classes, to be serialized.
+ */
 public class SubmitLabelAttribute {
   public String label;
   public String status;
diff --git a/java/com/google/gerrit/server/data/SubmitRecordAttribute.java b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
index fec870f..2c3d401 100644
--- a/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
@@ -16,7 +16,12 @@
 
 import java.util.List;
 
+/**
+ * Represents a {@link com.google.gerrit.common.data.SubmitRecord} that does not depend on Gerrit
+ * internal classes, to be serialized.
+ */
 public class SubmitRecordAttribute {
   public String status;
   public List<SubmitLabelAttribute> labels;
+  public List<SubmitRequirementAttribute> requirements;
 }
diff --git a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
new file mode 100644
index 0000000..8a0ea51
--- /dev/null
+++ b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2018 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.data;
+
+/**
+ * Represents a {@link com.google.gerrit.common.data.SubmitRequirement} that does not depend on
+ * Gerrit internal classes, to be serialized
+ */
+public class SubmitRequirementAttribute {
+  public String shortReason;
+  public String fullReason;
+  public String label;
+}
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index d356c2c..d3d52e4 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -52,6 +53,7 @@
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gerrit.server.data.SubmitLabelAttribute;
 import com.google.gerrit.server.data.SubmitRecordAttribute;
+import com.google.gerrit.server.data.SubmitRequirementAttribute;
 import com.google.gerrit.server.data.TrackingIdAttribute;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchList;
@@ -229,6 +231,7 @@
       sa.status = submitRecord.status.name();
       if (submitRecord.status != SubmitRecord.Status.RULE_ERROR) {
         addSubmitRecordLabels(submitRecord, sa);
+        addSubmitRecordRequirements(submitRecord, sa);
       }
       ca.submitRecords.add(sa);
     }
@@ -253,6 +256,19 @@
     }
   }
 
+  private void addSubmitRecordRequirements(SubmitRecord submitRecord, SubmitRecordAttribute sa) {
+    if (submitRecord.requirements != null && !submitRecord.requirements.isEmpty()) {
+      sa.requirements = new ArrayList<>();
+      for (SubmitRequirement req : submitRecord.requirements) {
+        SubmitRequirementAttribute re = new SubmitRequirementAttribute();
+        re.shortReason = req.shortReason();
+        re.fullReason = req.fullReason();
+        re.label = req.label().orElse(null);
+        sa.requirements.add(re);
+      }
+    }
+  }
+
   public void addDependencies(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs) {
     if (change == null || currentPs == null) {
       return;
diff --git a/java/com/google/gerrit/server/git/MergeOp.java b/java/com/google/gerrit/server/git/MergeOp.java
index b4a66f4..436faa7 100644
--- a/java/com/google/gerrit/server/git/MergeOp.java
+++ b/java/com/google/gerrit/server/git/MergeOp.java
@@ -291,7 +291,7 @@
       throw new ResourceConflictException("missing current patch set for change " + cd.getId());
     }
     List<SubmitRecord> results = getSubmitRecords(cd, allowClosed);
-    if (SubmitRecord.findOkRecord(results).isPresent()) {
+    if (SubmitRecord.allRecordsOK(results)) {
       // Rules supplied a valid solution.
       return;
     } else if (results.isEmpty()) {
@@ -303,6 +303,9 @@
 
     for (SubmitRecord record : results) {
       switch (record.status) {
+        case OK:
+          break;
+
         case CLOSED:
           throw new ResourceConflictException("change is closed");
 
@@ -313,7 +316,6 @@
           throw new ResourceConflictException(describeLabels(cd, record.labels));
 
         case FORCED:
-        case OK:
         default:
           throw new IllegalStateException(
               String.format(
diff --git a/java/com/google/gerrit/server/git/ProjectLevelConfig.java b/java/com/google/gerrit/server/git/ProjectLevelConfig.java
index 18dcef8..2044db0 100644
--- a/java/com/google/gerrit/server/git/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/git/ProjectLevelConfig.java
@@ -14,12 +14,16 @@
 
 package com.google.gerrit.server.git;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.ProjectState;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -53,6 +57,22 @@
   }
 
   public Config getWithInheritance() {
+    return getWithInheritance(false);
+  }
+
+  /**
+   * Get a Config that includes the values from all parent projects.
+   *
+   * <p>Merging means that matching sections/subsection will be merged to include the values from
+   * both parent and child config.
+   *
+   * <p>No merging means that matching sections/subsections in the child project will replace the
+   * corresponding value from the parent.
+   *
+   * @param merge whether to merge parent values with child values or not.
+   * @return a combined config.
+   */
+  public Config getWithInheritance(boolean merge) {
     Config cfgWithInheritance = new Config();
     try {
       cfgWithInheritance.fromText(get().toText());
@@ -65,21 +85,41 @@
       for (String section : parentCfg.getSections()) {
         Set<String> allNames = get().getNames(section);
         for (String name : parentCfg.getNames(section)) {
+          String[] parentValues = parentCfg.getStringList(section, null, name);
           if (!allNames.contains(name)) {
+            cfgWithInheritance.setStringList(section, null, name, Arrays.asList(parentValues));
+          } else if (merge) {
             cfgWithInheritance.setStringList(
-                section, null, name, Arrays.asList(parentCfg.getStringList(section, null, name)));
+                section,
+                null,
+                name,
+                Stream.concat(
+                        Arrays.stream(cfg.getStringList(section, null, name)),
+                        Arrays.stream(parentValues))
+                    .sorted()
+                    .distinct()
+                    .collect(toList()));
           }
         }
 
         for (String subsection : parentCfg.getSubsections(section)) {
           allNames = get().getNames(section, subsection);
           for (String name : parentCfg.getNames(section, subsection)) {
+            String[] parentValues = parentCfg.getStringList(section, subsection, name);
             if (!allNames.contains(name)) {
               cfgWithInheritance.setStringList(
+                  section, subsection, name, Arrays.asList(parentValues));
+            } else if (merge) {
+              cfgWithInheritance.setStringList(
                   section,
                   subsection,
                   name,
-                  Arrays.asList(parentCfg.getStringList(section, subsection, name)));
+                  Streams.concat(
+                          Arrays.stream(cfg.getStringList(section, subsection, name)),
+                          Arrays.stream(parentValues))
+                      .sorted()
+                      .distinct()
+                      .collect(toList()));
             }
           }
         }
diff --git a/java/com/google/gerrit/server/git/SubmoduleOp.java b/java/com/google/gerrit/server/git/SubmoduleOp.java
index 56ed432..0e3e146 100644
--- a/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import static java.util.Comparator.comparing;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
@@ -47,6 +49,7 @@
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
@@ -416,7 +419,10 @@
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     int count = 0;
-    for (SubmoduleSubscription s : targets.get(subscriber)) {
+
+    List<SubmoduleSubscription> subscriptions = new ArrayList<>(targets.get(subscriber));
+    Collections.sort(subscriptions, comparing((SubmoduleSubscription s) -> s.getPath()));
+    for (SubmoduleSubscription s : subscriptions) {
       if (count > 0) {
         msgbuf.append("\n\n");
       }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 344048f..262c732 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -36,7 +36,9 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Table;
 import com.google.common.primitives.Longs;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.Account;
@@ -657,8 +659,15 @@
       Integer appliedBy;
     }
 
+    static class StoredRequirement {
+      String shortReason;
+      String fullReason;
+      @Nullable String label;
+    }
+
     SubmitRecord.Status status;
     List<StoredLabel> labels;
+    List<StoredRequirement> requirements;
     String errorMessage;
 
     StoredSubmitRecord(SubmitRecord rec) {
@@ -674,6 +683,16 @@
           this.labels.add(sl);
         }
       }
+      if (rec.requirements != null) {
+        this.requirements = new ArrayList<>(rec.requirements.size());
+        for (SubmitRequirement requirement : rec.requirements) {
+          StoredRequirement sr = new StoredRequirement();
+          sr.shortReason = requirement.shortReason();
+          sr.fullReason = requirement.fullReason();
+          sr.label = requirement.label().orElse(null);
+          this.requirements.add(sr);
+        }
+      }
     }
 
     private SubmitRecord toSubmitRecord() {
@@ -690,6 +709,15 @@
           rec.labels.add(srl);
         }
       }
+      if (requirements != null) {
+        rec.requirements = new ArrayList<>(requirements.size());
+        for (StoredRequirement requirement : requirements) {
+          SubmitRequirement sr =
+              new SubmitRequirement(
+                  requirement.shortReason, requirement.fullReason, requirement.label);
+          rec.requirements.add(sr);
+        }
+      }
       return rec;
     }
   }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index a1da4ea..fcbdc57 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -14,29 +14,20 @@
 
 package com.google.gerrit.server.index.group;
 
-import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
-
 import com.google.common.collect.ImmutableSet;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.IndexUtils;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Optional;
-import java.util.concurrent.Future;
-import org.eclipse.jgit.lib.Config;
 
 public class GroupIndexerImpl implements GroupIndexer {
   public interface Factory {
@@ -48,8 +39,6 @@
   private final GroupCache groupCache;
   private final DynamicSet<GroupIndexedListener> indexedListener;
   private final StalenessChecker stalenessChecker;
-  private final ListeningExecutorService batchExecutor;
-  private final boolean autoReindexIfStale;
   @Nullable private final GroupIndexCollection indexes;
   @Nullable private final GroupIndex index;
 
@@ -58,14 +47,10 @@
       GroupCache groupCache,
       DynamicSet<GroupIndexedListener> indexedListener,
       StalenessChecker stalenessChecker,
-      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
-      @GerritServerConfig Config config,
       @Assisted GroupIndexCollection indexes) {
     this.groupCache = groupCache;
     this.indexedListener = indexedListener;
     this.stalenessChecker = stalenessChecker;
-    this.batchExecutor = batchExecutor;
-    this.autoReindexIfStale = autoReindexIfStale(config);
     this.indexes = indexes;
     this.index = null;
   }
@@ -75,14 +60,10 @@
       GroupCache groupCache,
       DynamicSet<GroupIndexedListener> indexedListener,
       StalenessChecker stalenessChecker,
-      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
-      @GerritServerConfig Config config,
       @Assisted @Nullable GroupIndex index) {
     this.groupCache = groupCache;
     this.indexedListener = indexedListener;
     this.stalenessChecker = stalenessChecker;
-    this.batchExecutor = batchExecutor;
-    this.autoReindexIfStale = autoReindexIfStale(config);
     this.indexes = null;
     this.index = index;
   }
@@ -100,7 +81,6 @@
       }
     }
     fireGroupIndexedEvent(uuid.get());
-    autoReindexIfStale(uuid);
   }
 
   @Override
@@ -112,35 +92,6 @@
     return false;
   }
 
-  private static boolean autoReindexIfStale(Config cfg) {
-    return cfg.getBoolean("index", null, "autoReindexIfStale", true);
-  }
-
-  private void autoReindexIfStale(AccountGroup.UUID uuid) {
-    if (autoReindexIfStale) {
-      // Don't retry indefinitely; if this fails the group will be stale.
-      @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError = reindexIfStaleAsync(uuid);
-    }
-  }
-
-  /**
-   * Asynchronously check if a group is stale, and reindex if it is.
-   *
-   * <p>Always run on the batch executor, even if this indexer instance is configured to use a
-   * different executor.
-   *
-   * @param uuid the unique identifier of the group.
-   * @return future for reindexing the group; returns true if the group was stale.
-   */
-  @SuppressWarnings("deprecation")
-  private com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStaleAsync(
-      AccountGroup.UUID uuid) {
-    return Futures.makeChecked(
-        Futures.nonCancellationPropagating(batchExecutor.submit(() -> reindexIfStale(uuid))),
-        IndexUtils.MAPPER);
-  }
-
   private void fireGroupIndexedEvent(String uuid) {
     for (GroupIndexedListener listener : indexedListener) {
       listener.onGroupIndexed(uuid);
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 7010be7..ccc5859 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -728,6 +728,7 @@
             msg.append('\n');
           }
         }
+        // TODO(maximeg) We might want to list plugins that validated this submission.
       }
     }
 
diff --git a/java/com/google/gerrit/server/project/RuleEvalException.java b/java/com/google/gerrit/server/project/RuleEvalException.java
index 9dae11c..7e3c739 100644
--- a/java/com/google/gerrit/server/project/RuleEvalException.java
+++ b/java/com/google/gerrit/server/project/RuleEvalException.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.project;
 
-@SuppressWarnings("serial")
 public class RuleEvalException extends Exception {
+  private static final long serialVersionUID = 1L;
+
   public RuleEvalException(String message) {
     super(message);
   }
 
-  RuleEvalException(String message, Throwable cause) {
+  public RuleEvalException(String message, Throwable cause) {
     super(message, cause);
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index c1da015..cb01b81 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -14,38 +14,21 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.PrologEnvironment;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.PrologRule;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.googlecode.prolog_cafe.exceptions.CompileException;
-import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.ListTerm;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import com.googlecode.prolog_cafe.lang.VariableTerm;
-import java.io.StringReader;
-import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -55,9 +38,31 @@
  */
 public class SubmitRuleEvaluator {
   private static final Logger log = LoggerFactory.getLogger(SubmitRuleEvaluator.class);
-
   private static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
 
+  private final ProjectCache projectCache;
+  private final PrologRule prologRule;
+  private final DynamicSet<SubmitRule> submitRules;
+  private final SubmitRuleOptions opts;
+
+  public interface Factory {
+    /** Returns a new {@link SubmitRuleEvaluator} with the specified options */
+    SubmitRuleEvaluator create(SubmitRuleOptions options);
+  }
+
+  @Inject
+  private SubmitRuleEvaluator(
+      ProjectCache projectCache,
+      PrologRule prologRule,
+      DynamicSet<SubmitRule> submitRules,
+      @Assisted SubmitRuleOptions options) {
+    this.projectCache = projectCache;
+    this.prologRule = prologRule;
+    this.submitRules = submitRules;
+
+    this.opts = options;
+  }
+
   public static List<SubmitRecord> defaultRuleError() {
     return createRuleError(DEFAULT_MSG);
   }
@@ -74,143 +79,25 @@
   }
 
   /**
-   * Exception thrown when the label term of a submit record unexpectedly didn't contain a user
-   * term.
-   */
-  private static class UserTermExpected extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    UserTermExpected(SubmitRecord.Label label) {
-      super(String.format("A label with the status %s must contain a user.", label.toString()));
-    }
-  }
-
-  public interface Factory {
-    SubmitRuleEvaluator create(ChangeData cd);
-  }
-
-  private final AccountCache accountCache;
-  private final Accounts accounts;
-  private final Emails emails;
-  private final ProjectCache projectCache;
-  private final ChangeData cd;
-
-  private SubmitRuleOptions.Builder optsBuilder = SubmitRuleOptions.builder();
-  private SubmitRuleOptions opts;
-  private Change change;
-  private PatchSet patchSet;
-  private boolean logErrors = true;
-  private long reductionsConsumed;
-  private ProjectState projectState;
-
-  private Term submitRule;
-
-  @Inject
-  SubmitRuleEvaluator(
-      AccountCache accountCache,
-      Accounts accounts,
-      Emails emails,
-      ProjectCache projectCache,
-      @Assisted ChangeData cd) {
-    this.accountCache = accountCache;
-    this.accounts = accounts;
-    this.emails = emails;
-    this.projectCache = projectCache;
-    this.cd = cd;
-  }
-
-  /**
-   * @return immutable snapshot of options configured so far. If neither {@link #getSubmitRule()}
-   *     nor {@link #getSubmitType()} have been called yet, state within this instance is still
-   *     mutable, so may change before evaluation. The instance's options are frozen at evaluation
-   *     time.
-   */
-  public SubmitRuleOptions getOptions() {
-    if (opts != null) {
-      return opts;
-    }
-    return optsBuilder.build();
-  }
-
-  public SubmitRuleEvaluator setOptions(SubmitRuleOptions opts) {
-    checkNotStarted();
-    if (opts != null) {
-      optsBuilder = opts.toBuilder();
-    } else {
-      optsBuilder = SubmitRuleOptions.builder();
-    }
-    return this;
-  }
-
-  /**
-   * @param ps patch set of the change to evaluate. If not set, the current patch set will be loaded
-   *     from {@link #evaluate()} or {@link #getSubmitType}.
-   * @return this
-   */
-  public SubmitRuleEvaluator setPatchSet(PatchSet ps) {
-    checkArgument(
-        ps.getId().getParentKey().equals(cd.getId()),
-        "Patch set %s does not match change %s",
-        ps.getId(),
-        cd.getId());
-    patchSet = ps;
-    return this;
-  }
-
-  /**
-   * @param allow whether to allow {@link #evaluate()} on closed changes.
-   * @return this
-   */
-  public SubmitRuleEvaluator setAllowClosed(boolean allow) {
-    checkNotStarted();
-    optsBuilder.allowClosed(allow);
-    return this;
-  }
-
-  /**
-   * @param skip if true, submit filter will not be applied.
-   * @return this
-   */
-  public SubmitRuleEvaluator setSkipSubmitFilters(boolean skip) {
-    checkNotStarted();
-    optsBuilder.skipFilters(skip);
-    return this;
-  }
-
-  /**
-   * @param rule custom rule to use, or null to use refs/meta/config:rules.pl.
-   * @return this
-   */
-  public SubmitRuleEvaluator setRule(@Nullable String rule) {
-    checkNotStarted();
-    optsBuilder.rule(rule);
-    return this;
-  }
-
-  /**
-   * @param log whether to log error messages in addition to returning error records. If true, error
-   *     record messages will be less descriptive.
-   */
-  public SubmitRuleEvaluator setLogErrors(boolean log) {
-    logErrors = log;
-    return this;
-  }
-
-  /** @return Prolog reductions consumed during evaluation. */
-  public long getReductionsConsumed() {
-    return reductionsConsumed;
-  }
-
-  /**
    * Evaluate the submit rules.
    *
    * @return List of {@link SubmitRecord} objects returned from the evaluated rules, including any
    *     errors.
+   * @param cd ChangeData to evaluate
    */
-  public List<SubmitRecord> evaluate() {
-    initOptions();
+  public List<SubmitRecord> evaluate(ChangeData cd) {
+    Change change;
+    ProjectState projectState;
     try {
-      init();
+      change = cd.change();
+      if (change == null) {
+        throw new OrmException("Change not found");
+      }
+
+      projectState = projectCache.get(cd.project());
+      if (projectState == null) {
+        throw new NoSuchProjectException(cd.project());
+      }
     } catch (OrmException | NoSuchProjectException e) {
       return ruleError("Error looking up change " + cd.getId(), e);
     }
@@ -221,138 +108,16 @@
       return Collections.singletonList(rec);
     }
 
-    List<Term> results;
-    try {
-      results =
-          evaluateImpl(
-              "locate_submit_rule", "can_submit", "locate_submit_filter", "filter_submit_results");
-    } catch (RuleEvalException e) {
-      return ruleError(e.getMessage(), e);
-    }
-
-    if (results.isEmpty()) {
-      // This should never occur. A well written submit rule will always produce
-      // at least one result informing the caller of the labels that are
-      // required for this change to be submittable. Each label will indicate
-      // whether or not that is actually possible given the permissions.
-      return ruleError(
-          String.format(
-              "Submit rule '%s' for change %s of %s has no solution.",
-              getSubmitRuleName(), cd.getId(), getProjectName()));
-    }
-
-    return resultsToSubmitRecord(getSubmitRule(), results);
-  }
-
-  /**
-   * Convert the results from Prolog Cafe's format to Gerrit's common format.
-   *
-   * <p>can_submit/1 terminates when an ok(P) record is found. Therefore walk the results backwards,
-   * using only that ok(P) record if it exists. This skips partial results that occur early in the
-   * output. Later after the loop the out collection is reversed to restore it to the original
-   * ordering.
-   */
-  private List<SubmitRecord> resultsToSubmitRecord(Term submitRule, List<Term> results) {
-    List<SubmitRecord> out = new ArrayList<>(results.size());
-    for (int resultIdx = results.size() - 1; 0 <= resultIdx; resultIdx--) {
-      Term submitRecord = results.get(resultIdx);
-      SubmitRecord rec = new SubmitRecord();
-      out.add(rec);
-
-      if (!(submitRecord instanceof StructureTerm) || 1 != submitRecord.arity()) {
-        return invalidResult(submitRule, submitRecord);
-      }
-
-      if ("ok".equals(submitRecord.name())) {
-        rec.status = SubmitRecord.Status.OK;
-
-      } else if ("not_ready".equals(submitRecord.name())) {
-        rec.status = SubmitRecord.Status.NOT_READY;
-
-      } else {
-        return invalidResult(submitRule, submitRecord);
-      }
-
-      // Unpack the one argument. This should also be a structure with one
-      // argument per label that needs to be reported on to the caller.
-      //
-      submitRecord = submitRecord.arg(0);
-
-      if (!(submitRecord instanceof StructureTerm)) {
-        return invalidResult(submitRule, submitRecord);
-      }
-
-      rec.labels = new ArrayList<>(submitRecord.arity());
-
-      for (Term state : ((StructureTerm) submitRecord).args()) {
-        if (!(state instanceof StructureTerm)
-            || 2 != state.arity()
-            || !"label".equals(state.name())) {
-          return invalidResult(submitRule, submitRecord);
-        }
-
-        SubmitRecord.Label lbl = new SubmitRecord.Label();
-        rec.labels.add(lbl);
-
-        lbl.label = state.arg(0).name();
-        Term status = state.arg(1);
-
-        try {
-          if ("ok".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.OK;
-            appliedBy(lbl, status);
-
-          } else if ("reject".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.REJECT;
-            appliedBy(lbl, status);
-
-          } else if ("need".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.NEED;
-
-          } else if ("may".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.MAY;
-
-          } else if ("impossible".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.IMPOSSIBLE;
-
-          } else {
-            return invalidResult(submitRule, submitRecord);
-          }
-        } catch (UserTermExpected e) {
-          return invalidResult(submitRule, submitRecord, e.getMessage());
-        }
-      }
-
-      if (rec.status == SubmitRecord.Status.OK) {
-        break;
-      }
-    }
-    Collections.reverse(out);
-
-    return out;
-  }
-
-  private List<SubmitRecord> invalidResult(Term rule, Term record, String reason) {
-    return ruleError(
-        String.format(
-            "Submit rule %s for change %s of %s output invalid result: %s%s",
-            rule,
-            cd.getId(),
-            getProjectName(),
-            record,
-            (reason == null ? "" : ". Reason: " + reason)));
-  }
-
-  private List<SubmitRecord> invalidResult(Term rule, Term record) {
-    return invalidResult(rule, record, null);
-  }
-
-  private List<SubmitRecord> ruleError(String err) {
-    return ruleError(err, null);
+    // We evaluate all the plugin-defined evaluators,
+    // and then we collect the results in one list.
+    return StreamSupport.stream(submitRules.spliterator(), false)
+        .map(s -> s.evaluate(cd, opts))
+        .flatMap(Collection::stream)
+        .collect(Collectors.toList());
   }
 
   private List<SubmitRecord> ruleError(String err, Exception e) {
-    if (logErrors) {
+    if (opts.logErrors()) {
       if (e == null) {
         log.error(err);
       } else {
@@ -367,73 +132,24 @@
    * Evaluate the submit type rules to get the submit type.
    *
    * @return record from the evaluated rules.
+   * @param cd
    */
-  public SubmitTypeRecord getSubmitType() {
-    initOptions();
+  public SubmitTypeRecord getSubmitType(ChangeData cd) {
+    ProjectState projectState;
     try {
-      init();
-    } catch (OrmException | NoSuchProjectException e) {
+      projectState = projectCache.get(cd.project());
+      if (projectState == null) {
+        throw new NoSuchProjectException(cd.project());
+      }
+    } catch (NoSuchProjectException e) {
       return typeError("Error looking up change " + cd.getId(), e);
     }
 
-    List<Term> results;
-    try {
-      results =
-          evaluateImpl(
-              "locate_submit_type",
-              "get_submit_type",
-              "locate_submit_type_filter",
-              "filter_submit_type_results");
-    } catch (RuleEvalException e) {
-      return typeError(e.getMessage(), e);
-    }
-
-    if (results.isEmpty()) {
-      // Should never occur for a well written rule
-      return typeError(
-          "Submit rule '"
-              + getSubmitRuleName()
-              + "' for change "
-              + cd.getId()
-              + " of "
-              + getProjectName()
-              + " has no solution.");
-    }
-
-    Term typeTerm = results.get(0);
-    if (!(typeTerm instanceof SymbolTerm)) {
-      return typeError(
-          "Submit rule '"
-              + getSubmitRuleName()
-              + "' for change "
-              + cd.getId()
-              + " of "
-              + getProjectName()
-              + " did not return a symbol.");
-    }
-
-    String typeName = ((SymbolTerm) typeTerm).name();
-    try {
-      return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase()));
-    } catch (IllegalArgumentException e) {
-      return typeError(
-          "Submit type rule "
-              + getSubmitRule()
-              + " for change "
-              + cd.getId()
-              + " of "
-              + getProjectName()
-              + " output invalid result: "
-              + typeName);
-    }
-  }
-
-  private SubmitTypeRecord typeError(String err) {
-    return typeError(err, null);
+    return prologRule.getSubmitType(cd, opts);
   }
 
   private SubmitTypeRecord typeError(String err, Exception e) {
-    if (logErrors) {
+    if (opts.logErrors()) {
       if (e == null) {
         log.error(err);
       } else {
@@ -443,194 +159,4 @@
     }
     return SubmitTypeRecord.error(err);
   }
-
-  private List<Term> evaluateImpl(
-      String userRuleLocatorName,
-      String userRuleWrapperName,
-      String filterRuleLocatorName,
-      String filterRuleWrapperName)
-      throws RuleEvalException {
-    PrologEnvironment env = getPrologEnvironment();
-    try {
-      Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm());
-      List<Term> results = new ArrayList<>();
-      try {
-        for (Term[] template : env.all("gerrit", userRuleWrapperName, sr, new VariableTerm())) {
-          results.add(template[1]);
-        }
-      } catch (ReductionLimitException err) {
-        throw new RuleEvalException(
-            String.format(
-                "%s on change %d of %s", err.getMessage(), cd.getId().get(), getProjectName()));
-      } catch (RuntimeException err) {
-        throw new RuleEvalException(
-            String.format(
-                "Exception calling %s on change %d of %s", sr, cd.getId().get(), getProjectName()),
-            err);
-      } finally {
-        reductionsConsumed = env.getReductions();
-      }
-
-      Term resultsTerm = toListTerm(results);
-      if (!opts.skipFilters()) {
-        resultsTerm =
-            runSubmitFilters(resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName);
-      }
-      List<Term> r;
-      if (resultsTerm instanceof ListTerm) {
-        r = new ArrayList<>();
-        for (Term t = resultsTerm; t instanceof ListTerm; ) {
-          ListTerm l = (ListTerm) t;
-          r.add(l.car().dereference());
-          t = l.cdr().dereference();
-        }
-      } else {
-        r = Collections.emptyList();
-      }
-      submitRule = sr;
-      return r;
-    } finally {
-      env.close();
-    }
-  }
-
-  private PrologEnvironment getPrologEnvironment() throws RuleEvalException {
-    PrologEnvironment env;
-    try {
-      if (opts.rule() == null) {
-        env = projectState.newPrologEnvironment();
-      } else {
-        env = projectState.newPrologEnvironment("stdin", new StringReader(opts.rule()));
-      }
-    } catch (CompileException err) {
-      String msg;
-      if (opts.rule() == null) {
-        msg = String.format("Cannot load rules.pl for %s: %s", getProjectName(), err.getMessage());
-      } else {
-        msg = err.getMessage();
-      }
-      throw new RuleEvalException(msg, err);
-    }
-    env.set(StoredValues.ACCOUNTS, accounts);
-    env.set(StoredValues.ACCOUNT_CACHE, accountCache);
-    env.set(StoredValues.EMAILS, emails);
-    env.set(StoredValues.REVIEW_DB, cd.db());
-    env.set(StoredValues.CHANGE_DATA, cd);
-    env.set(StoredValues.PROJECT_STATE, projectState);
-    return env;
-  }
-
-  private Term runSubmitFilters(
-      Term results,
-      PrologEnvironment env,
-      String filterRuleLocatorName,
-      String filterRuleWrapperName)
-      throws RuleEvalException {
-    PrologEnvironment childEnv = env;
-    for (ProjectState parentState : projectState.parents()) {
-      PrologEnvironment parentEnv;
-      try {
-        parentEnv = parentState.newPrologEnvironment();
-      } catch (CompileException err) {
-        throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
-      }
-
-      parentEnv.copyStoredValues(childEnv);
-      Term filterRule = parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm());
-      try {
-        Term[] template =
-            parentEnv.once(
-                "gerrit", filterRuleWrapperName, filterRule, results, new VariableTerm());
-        results = template[2];
-      } catch (ReductionLimitException err) {
-        throw new RuleEvalException(
-            String.format(
-                "%s on change %d of %s",
-                err.getMessage(), cd.getId().get(), parentState.getName()));
-      } catch (RuntimeException err) {
-        throw new RuleEvalException(
-            String.format(
-                "Exception calling %s on change %d of %s",
-                filterRule, cd.getId().get(), parentState.getName()),
-            err);
-      } finally {
-        reductionsConsumed += env.getReductions();
-      }
-      childEnv = parentEnv;
-    }
-    return results;
-  }
-
-  private static Term toListTerm(List<Term> terms) {
-    Term list = Prolog.Nil;
-    for (int i = terms.size() - 1; i >= 0; i--) {
-      list = new ListTerm(terms.get(i), list);
-    }
-    return list;
-  }
-
-  private void appliedBy(SubmitRecord.Label label, Term status) throws UserTermExpected {
-    if (status instanceof StructureTerm && status.arity() == 1) {
-      Term who = status.arg(0);
-      if (isUser(who)) {
-        label.appliedBy = new Account.Id(((IntegerTerm) who.arg(0)).intValue());
-      } else {
-        throw new UserTermExpected(label);
-      }
-    }
-  }
-
-  private static boolean isUser(Term who) {
-    return who instanceof StructureTerm
-        && who.arity() == 1
-        && who.name().equals("user")
-        && who.arg(0) instanceof IntegerTerm;
-  }
-
-  public Term getSubmitRule() {
-    checkState(submitRule != null, "getSubmitRule() invalid before evaluation");
-    return submitRule;
-  }
-
-  public String getSubmitRuleName() {
-    return submitRule != null ? submitRule.toString() : "<unknown rule>";
-  }
-
-  private void checkNotStarted() {
-    checkState(opts == null, "cannot set options after starting evaluation");
-  }
-
-  private void initOptions() {
-    if (opts == null) {
-      opts = optsBuilder.build();
-      optsBuilder = null;
-    }
-  }
-
-  private void init() throws OrmException, NoSuchProjectException {
-    if (change == null) {
-      change = cd.change();
-      if (change == null) {
-        throw new OrmException("No change found");
-      }
-    }
-
-    if (projectState == null) {
-      projectState = projectCache.get(change.getProject());
-      if (projectState == null) {
-        throw new NoSuchProjectException(change.getProject());
-      }
-    }
-
-    if (patchSet == null) {
-      patchSet = cd.currentPatchSet();
-      if (patchSet == null) {
-        throw new OrmException("No patch set found");
-      }
-    }
-  }
-
-  private String getProjectName() {
-    return projectState.getName();
-  }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
index 332aa75..a4340b2 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleOptions.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -25,17 +25,28 @@
  */
 @AutoValue
 public abstract class SubmitRuleOptions {
+  private static final SubmitRuleOptions defaults =
+      new AutoValue_SubmitRuleOptions.Builder()
+          .allowClosed(false)
+          .skipFilters(false)
+          .logErrors(true)
+          .rule(null)
+          .build();
+
+  public static SubmitRuleOptions defaults() {
+    return defaults;
+  }
+
   public static Builder builder() {
-    return new AutoValue_SubmitRuleOptions.Builder()
-        .allowClosed(false)
-        .skipFilters(false)
-        .rule(null);
+    return defaults.toBuilder();
   }
 
   public abstract boolean allowClosed();
 
   public abstract boolean skipFilters();
 
+  public abstract boolean logErrors();
+
   @Nullable
   public abstract String rule();
 
@@ -49,6 +60,8 @@
 
     public abstract SubmitRuleOptions.Builder rule(@Nullable String rule);
 
+    public abstract SubmitRuleOptions.Builder logErrors(boolean logErrors);
+
     public abstract SubmitRuleOptions build();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 6dfc40b..fccb14a 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -943,7 +943,7 @@
       if (!lazyLoad) {
         return Collections.emptyList();
       }
-      records = submitRuleEvaluatorFactory.create(this).setOptions(options).evaluate();
+      records = submitRuleEvaluatorFactory.create(options).evaluate(this);
       submitRecords.put(options, records);
     }
     return records;
@@ -960,7 +960,8 @@
 
   public SubmitTypeRecord submitTypeRecord() {
     if (submitTypeRecord == null) {
-      submitTypeRecord = submitRuleEvaluatorFactory.create(this).getSubmitType();
+      submitTypeRecord =
+          submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults()).getSubmitType(this);
     }
     return submitTypeRecord;
   }
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 64c6208..0806edf 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -249,8 +250,8 @@
     }
 
     if (includeSubmitRecords) {
-      eventFactory.addSubmitRecords(
-          c, submitRuleEvaluatorFactory.create(d).setAllowClosed(true).evaluate());
+      SubmitRuleOptions options = SubmitRuleOptions.builder().allowClosed(true).build();
+      eventFactory.addSubmitRecords(c, submitRuleEvaluatorFactory.create(options).evaluate(d));
     }
 
     if (includeCommitMessage) {
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 47d76b2..4f8064b 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -70,7 +71,7 @@
   private final Provider<ReviewDb> db;
   private final ChangeIndexer indexer;
   private final MergeabilityCache cache;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+  private final SubmitRuleEvaluator submitRuleEvaluator;
 
   @Inject
   Mergeable(
@@ -89,7 +90,7 @@
     this.db = db;
     this.indexer = indexer;
     this.cache = cache;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
   }
 
   public void setOtherBranches(boolean otherBranches) {
@@ -112,7 +113,7 @@
     }
 
     ChangeData cd = changeDataFactory.create(db.get(), resource.getNotes());
-    result.submitType = getSubmitType(cd, ps);
+    result.submitType = getSubmitType(cd);
 
     try (Repository git = gitManager.openRepository(change.getProject())) {
       ObjectId commit = toId(ps);
@@ -144,9 +145,8 @@
     return result;
   }
 
-  private SubmitType getSubmitType(ChangeData cd, PatchSet patchSet) throws OrmException {
-    SubmitTypeRecord rec =
-        submitRuleEvaluatorFactory.create(cd).setPatchSet(patchSet).getSubmitType();
+  private SubmitType getSubmitType(ChangeData cd) throws OrmException {
+    SubmitTypeRecord rec = submitRuleEvaluator.getSubmitType(cd);
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new OrmException("Submit type rule failed: " + rec);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 4ff3862..f9d7083 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -194,13 +194,20 @@
 
     Addition byAccountId =
         addByAccountId(reviewer, rsrc, state, notify, accountsToNotify, allowGroup, allowByEmail);
+
+    Addition wholeGroup = null;
+    if (byAccountId == null || !byAccountId.exactMatchFound) {
+      wholeGroup =
+          addWholeGroup(
+              reviewer, rsrc, state, notify, accountsToNotify, confirmed, allowGroup, allowByEmail);
+      if (wholeGroup != null && wholeGroup.exactMatchFound) {
+        return wholeGroup;
+      }
+    }
+
     if (byAccountId != null) {
       return byAccountId;
     }
-
-    Addition wholeGroup =
-        addWholeGroup(
-            reviewer, rsrc, state, notify, accountsToNotify, confirmed, allowGroup, allowByEmail);
     if (wholeGroup != null) {
       return wholeGroup;
     }
@@ -216,7 +223,8 @@
         null,
         CC,
         NotifyHandling.NONE,
-        ImmutableListMultimap.of());
+        ImmutableListMultimap.of(),
+        true);
   }
 
   @Nullable
@@ -229,9 +237,14 @@
       boolean allowGroup,
       boolean allowByEmail)
       throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
-    Account.Id accountId = null;
+    IdentifiedUser user = null;
+    boolean exactMatchFound = false;
     try {
-      accountId = accounts.parse(reviewer).getAccountId();
+      user = accounts.parse(reviewer);
+      if (reviewer.equalsIgnoreCase(user.getName())
+          || reviewer.equals(String.valueOf(user.getAccountId()))) {
+        exactMatchFound = true;
+      }
     } catch (UnprocessableEntityException | AuthException e) {
       // AuthException won't occur since the user is authenticated at this point.
       if (!allowGroup && !allowByEmail) {
@@ -242,13 +255,20 @@
       return null;
     }
 
-    ReviewerResource rrsrc = reviewerFactory.create(rsrc, accountId);
+    ReviewerResource rrsrc = reviewerFactory.create(rsrc, user.getAccountId());
     Account member = rrsrc.getReviewerUser().getAccount();
     PermissionBackend.ForRef perm =
         permissionBackend.user(rrsrc.getReviewerUser()).ref(rrsrc.getChange().getDest());
     if (isValidReviewer(member, perm)) {
       return new Addition(
-          reviewer, rsrc, ImmutableSet.of(member.getId()), null, state, notify, accountsToNotify);
+          reviewer,
+          rsrc,
+          ImmutableSet.of(member.getId()),
+          null,
+          state,
+          notify,
+          accountsToNotify,
+          exactMatchFound);
     }
     if (!member.isActive()) {
       if (allowByEmail && state == CC) {
@@ -328,7 +348,7 @@
       }
     }
 
-    return new Addition(reviewer, rsrc, reviewers, null, state, notify, accountsToNotify);
+    return new Addition(reviewer, rsrc, reviewers, null, state, notify, accountsToNotify, true);
   }
 
   @Nullable
@@ -357,7 +377,7 @@
       return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
     }
     return new Addition(
-        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify);
+        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify, true);
   }
 
   private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
@@ -395,6 +415,7 @@
     final ReviewerState state;
     final ChangeNotes notes;
     final IdentifiedUser caller;
+    final boolean exactMatchFound;
 
     Addition(String reviewer) {
       result = new AddReviewerResult(reviewer);
@@ -404,6 +425,7 @@
       state = REVIEWER;
       notes = null;
       caller = null;
+      exactMatchFound = false;
     }
 
     protected Addition(
@@ -413,7 +435,8 @@
         @Nullable Collection<Address> reviewersByEmail,
         ReviewerState state,
         @Nullable NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+        ListMultimap<RecipientType, Account.Id> accountsToNotify,
+        boolean exactMatchFound) {
       checkArgument(
           reviewers != null || reviewersByEmail != null,
           "must have either reviewers or reviewersByEmail");
@@ -427,6 +450,7 @@
       op =
           postReviewersOpFactory.create(
               rsrc, this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
+      this.exactMatchFound = exactMatchFound;
     }
 
     void gatherResults() throws OrmException, PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index b55ca5e..4545794 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -18,7 +18,10 @@
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -43,6 +46,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mail.send.RevertedSender;
@@ -70,6 +74,7 @@
 import java.text.MessageFormat;
 import java.util.HashSet;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -102,6 +107,7 @@
   private final ChangeReverted changeReverted;
   private final ContributorAgreementsChecker contributorAgreements;
   private final ProjectCache projectCache;
+  private final NotifyUtil notifyUtil;
 
   @Inject
   Revert(
@@ -119,7 +125,8 @@
       ApprovalsUtil approvalsUtil,
       ChangeReverted changeReverted,
       ContributorAgreementsChecker contributorAgreements,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      NotifyUtil notifyUtil) {
     super(retryHelper);
     this.db = db;
     this.permissionBackend = permissionBackend;
@@ -135,13 +142,14 @@
     this.changeReverted = changeReverted;
     this.contributorAgreements = contributorAgreements;
     this.projectCache = projectCache;
+    this.notifyUtil = notifyUtil;
   }
 
   @Override
   public ChangeInfo applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, RevertInput input)
       throws IOException, OrmException, RestApiException, UpdateException, NoSuchChangeException,
-          PermissionBackendException, NoSuchProjectException {
+          PermissionBackendException, NoSuchProjectException, ConfigInvalidException {
     Change change = rsrc.getChange();
     if (change.getStatus() != Change.Status.MERGED) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
@@ -151,14 +159,14 @@
     permissionBackend.user(rsrc.getUser()).ref(change.getDest()).check(CREATE_CHANGE);
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
-    Change.Id revertId =
-        revert(updateFactory, rsrc.getNotes(), rsrc.getUser(), Strings.emptyToNull(input.message));
+    Change.Id revertId = revert(updateFactory, rsrc.getNotes(), rsrc.getUser(), input);
     return json.noOptions().format(rsrc.getProject(), revertId);
   }
 
   private Change.Id revert(
-      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, String message)
-      throws OrmException, IOException, RestApiException, UpdateException {
+      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, RevertInput input)
+      throws OrmException, IOException, RestApiException, UpdateException, ConfigInvalidException {
+    String message = Strings.emptyToNull(input.message);
     Change.Id changeIdToRevert = notes.getChangeId();
     PatchSet.Id patchSetId = notes.getChange().currentPatchSetId();
     PatchSet patch = psUtil.get(db.get(), notes, patchSetId);
@@ -213,11 +221,16 @@
       ObjectId id = oi.insert(revertCommitBuilder);
       RevCommit revertCommit = revWalk.parseCommit(id);
 
+      ListMultimap<RecipientType, Account.Id> accountsToNotify =
+          notifyUtil.resolveAccounts(input.notifyDetails);
+
       ChangeInserter ins =
           changeInserterFactory
               .create(changeId, revertCommit, notes.getChange().getDest().get())
               .setTopic(changeToRevert.getTopic());
       ins.setMessage("Uploaded patch set 1.");
+      ins.setNotify(input.notify);
+      ins.setAccountsToNotify(accountsToNotify);
 
       ReviewerSet reviewerSet = approvalsUtil.getReviewers(db.get(), notes);
 
@@ -235,7 +248,7 @@
       try (BatchUpdate bu = updateFactory.create(db.get(), project, user, now)) {
         bu.setRepository(git, revWalk, oi);
         bu.insertChange(ins);
-        bu.addOp(changeId, new NotifyOp(notes.getChange(), ins));
+        bu.addOp(changeId, new NotifyOp(changeToRevert, ins, input.notify, accountsToNotify));
         bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(computedChangeId));
         bu.execute();
       }
@@ -269,23 +282,31 @@
   private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
+    private final NotifyHandling notifyHandling;
+    private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
 
-    NotifyOp(Change change, ChangeInserter ins) {
+    NotifyOp(
+        Change change,
+        ChangeInserter ins,
+        NotifyHandling notifyHandling,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
       this.change = change;
       this.ins = ins;
+      this.notifyHandling = notifyHandling;
+      this.accountsToNotify = accountsToNotify;
     }
 
     @Override
     public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(change, ins.getChange(), ctx.getWhen());
-      Change.Id changeId = ins.getChange().getId();
       try {
-        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), changeId);
+        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
-        cm.setChangeMessage(ins.getChangeMessage().getMessage(), ctx.getWhen());
+        cm.setNotify(notifyHandling);
+        cm.setAccountsToNotify(accountsToNotify);
         cm.send();
       } catch (Exception err) {
-        log.error("Cannot send email for revert change " + changeId, err);
+        log.error("Cannot send email for revert change " + change.getId(), err);
       }
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
index ef5b432..d74be7d 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -49,7 +50,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+  private final SubmitRuleEvaluator submitRuleEvaluator;
 
   @Inject
   ReviewerJson(
@@ -64,7 +65,7 @@
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
   }
 
   public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
@@ -124,7 +125,7 @@
     // do not exist in the DB.
     PatchSet ps = cd.currentPatchSet();
     if (ps != null) {
-      for (SubmitRecord rec : submitRuleEvaluatorFactory.create(cd).evaluate()) {
+      for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
         if (rec.labels == null) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index eb356ed..2a18612 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gwtorm.server.OrmException;
@@ -70,24 +71,22 @@
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
-    SubmitRuleEvaluator evaluator =
-        submitRuleEvaluatorFactory.create(changeDataFactory.create(db.get(), rsrc.getNotes()));
 
-    List<SubmitRecord> records =
-        evaluator
-            .setPatchSet(rsrc.getPatchSet())
-            .setLogErrors(false)
-            .setSkipSubmitFilters(input.filters == Filters.SKIP)
-            .setRule(input.rule)
-            .evaluate();
+    SubmitRuleOptions opts =
+        SubmitRuleOptions.builder()
+            .skipFilters(input.filters == Filters.SKIP)
+            .rule(input.rule)
+            .logErrors(false)
+            .build();
+
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    List<SubmitRecord> records = submitRuleEvaluatorFactory.create(opts).evaluate(cd);
+
     List<Record> out = Lists.newArrayListWithCapacity(records.size());
     AccountLoader accounts = accountInfoFactory.create(true);
     for (SubmitRecord r : records) {
       out.add(new Record(r, accounts));
     }
-    if (!out.isEmpty()) {
-      out.get(0).prologReductionCount = evaluator.getReductionsConsumed();
-    }
     accounts.fill();
     return out;
   }
@@ -100,7 +99,6 @@
     Map<String, None> need;
     Map<String, AccountInfo> may;
     Map<String, None> impossible;
-    Long prologReductionCount;
 
     Record(SubmitRecord r, AccountLoader accounts) {
       this.status = r.status;
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index d8d5d0a..c1be1ce 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gwtorm.server.OrmException;
@@ -64,19 +65,20 @@
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
-    SubmitRuleEvaluator evaluator =
-        submitRuleEvaluatorFactory.create(changeDataFactory.create(db.get(), rsrc.getNotes()));
 
-    SubmitTypeRecord rec =
-        evaluator
-            .setPatchSet(rsrc.getPatchSet())
-            .setLogErrors(false)
-            .setSkipSubmitFilters(input.filters == Filters.SKIP)
-            .setRule(input.rule)
-            .getSubmitType();
+    SubmitRuleOptions opts =
+        SubmitRuleOptions.builder()
+            .logErrors(false)
+            .skipFilters(input.filters == Filters.SKIP)
+            .rule(input.rule)
+            .build();
+
+    SubmitRuleEvaluator evaluator = submitRuleEvaluatorFactory.create(opts);
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    SubmitTypeRecord rec = evaluator.getSubmitType(cd);
+
     if (rec.status != SubmitTypeRecord.Status.OK) {
-      throw new BadRequestException(
-          String.format("rule %s produced invalid result: %s", evaluator.getSubmitRuleName(), rec));
+      throw new BadRequestException(String.format("rule produced invalid result: %s", rec));
     }
 
     return rec.type;
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index 26f069c..a382436 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -37,7 +37,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
+import org.eclipse.jgit.storage.file.WindowCacheStats;
 import org.kohsuke.args4j.Option;
 
 @RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
@@ -125,8 +125,8 @@
     long mTotal = r.totalMemory();
     long mInuse = mTotal - mFree;
 
-    int jgitOpen = WindowCacheStatAccessor.getOpenFiles();
-    long jgitBytes = WindowCacheStatAccessor.getOpenBytes();
+    int jgitOpen = WindowCacheStats.getOpenFiles();
+    long jgitBytes = WindowCacheStats.getOpenBytes();
 
     MemSummaryInfo memSummaryInfo = new MemSummaryInfo();
     memSummaryInfo.total = bytes(mTotal);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index eb44118..365fe89 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
@@ -110,6 +111,7 @@
   private final Provider<IdentifiedUser> identifiedUser;
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
+  private final AllUsersName allUsers;
   private final DynamicItem<ProjectNameLockManager> lockManager;
   private final String name;
 
@@ -131,6 +133,7 @@
       Provider<IdentifiedUser> identifiedUser,
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
+      AllUsersName allUsers,
       DynamicItem<ProjectNameLockManager> lockManager,
       @Assisted String name) {
     this.projectsCollection = projectsCollection;
@@ -149,6 +152,7 @@
     this.identifiedUser = identifiedUser;
     this.putConfig = putConfig;
     this.allProjects = allProjects;
+    this.allUsers = allUsers;
     this.lockManager = lockManager;
     this.name = name;
   }
@@ -169,6 +173,10 @@
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
     args.newParent = projectsCollection.get().parse(parentName, false).getNameKey();
+    if (args.newParent.equals(allUsers)) {
+      throw new ResourceConflictException(
+          String.format("Cannot inherit from '%s' project", allUsers.get()));
+    }
     args.createEmptyCommit = input.createEmptyCommit;
     args.permissionsOnly = input.permissionsOnly;
     args.projectDescription = Strings.emptyToNull(input.description);
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 21fef97..2e51929 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -126,6 +126,11 @@
       throw new ResourceConflictException("cannot set parent of " + allProjects.get());
     }
 
+    if (allUsers.get().equals(newParent)) {
+      throw new ResourceConflictException(
+          String.format("Cannot inherit from '%s' project", allUsers.get()));
+    }
+
     newParent = Strings.emptyToNull(newParent);
     if (newParent != null) {
       ProjectState parent = cache.get(new Project.NameKey(newParent));
diff --git a/java/com/google/gerrit/server/rules/PrologModule.java b/java/com/google/gerrit/server/rules/PrologModule.java
index 1a8b46c..e6de229 100644
--- a/java/com/google/gerrit/server/rules/PrologModule.java
+++ b/java/com/google/gerrit/server/rules/PrologModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.rules;
 
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicSet;
 
@@ -22,6 +23,9 @@
   protected void configure() {
     install(new EnvironmentModule());
     bind(PrologEnvironment.Args.class);
+    factory(PrologRuleEvaluator.Factory.class);
+
+    bind(SubmitRule.class).annotatedWith(Exports.named("PrologRule")).to(PrologRule.class);
   }
 
   static class EnvironmentModule extends FactoryModule {
diff --git a/java/com/google/gerrit/server/rules/PrologRule.java b/java/com/google/gerrit/server/rules/PrologRule.java
new file mode 100644
index 0000000..44f18c5
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PrologRule.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 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.rules;
+
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.PrologRuleEvaluator.Factory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collection;
+
+@Singleton
+public class PrologRule implements SubmitRule {
+
+  private final Factory factory;
+
+  @Inject
+  private PrologRule(PrologRuleEvaluator.Factory factory) {
+    this.factory = factory;
+  }
+
+  @Override
+  public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions opts) {
+    return getEvaluator(cd, opts).evaluate();
+  }
+
+  private PrologRuleEvaluator getEvaluator(ChangeData cd, SubmitRuleOptions opts) {
+    return factory.create(cd, opts);
+  }
+
+  public SubmitTypeRecord getSubmitType(ChangeData cd, SubmitRuleOptions opts) {
+    return getEvaluator(cd, opts).getSubmitType();
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
new file mode 100644
index 0000000..f2d3c19
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -0,0 +1,519 @@
+// Copyright (C) 2012 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.rules;
+
+import static com.google.gerrit.server.project.SubmitRuleEvaluator.createRuleError;
+import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultRuleError;
+import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultTypeError;
+
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.RuleEvalException;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import com.googlecode.prolog_cafe.exceptions.CompileException;
+import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.ListTerm;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
+ * the results through rules found in the parent projects, all the way up to All-Projects.
+ */
+public class PrologRuleEvaluator {
+  private static final Logger log = LoggerFactory.getLogger(PrologRuleEvaluator.class);
+
+  public interface Factory {
+    /** Returns a new {@link PrologRuleEvaluator} with the specified options */
+    PrologRuleEvaluator create(ChangeData cd, SubmitRuleOptions options);
+  }
+
+  /**
+   * Exception thrown when the label term of a submit record unexpectedly didn't contain a user
+   * term.
+   */
+  private static class UserTermExpected extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    UserTermExpected(SubmitRecord.Label label) {
+      super(String.format("A label with the status %s must contain a user.", label.toString()));
+    }
+  }
+
+  private final AccountCache accountCache;
+  private final Accounts accounts;
+  private final Emails emails;
+  private final ChangeData cd;
+  private final ProjectState projectState;
+  private final SubmitRuleOptions opts;
+  private Term submitRule;
+
+  @AssistedInject
+  private PrologRuleEvaluator(
+      AccountCache accountCache,
+      Accounts accounts,
+      Emails emails,
+      ProjectCache projectCache,
+      @Assisted ChangeData cd,
+      @Assisted SubmitRuleOptions options) {
+    this.accountCache = accountCache;
+    this.accounts = accounts;
+    this.emails = emails;
+    this.cd = cd;
+    this.opts = options;
+
+    this.projectState = projectCache.get(cd.project());
+  }
+
+  private static Term toListTerm(List<Term> terms) {
+    Term list = Prolog.Nil;
+    for (int i = terms.size() - 1; i >= 0; i--) {
+      list = new ListTerm(terms.get(i), list);
+    }
+    return list;
+  }
+
+  private static boolean isUser(Term who) {
+    return who instanceof StructureTerm
+        && who.arity() == 1
+        && who.name().equals("user")
+        && who.arg(0) instanceof IntegerTerm;
+  }
+
+  private Term getSubmitRule() {
+    return submitRule;
+  }
+
+  /**
+   * Evaluate the submit rules.
+   *
+   * @return List of {@link SubmitRecord} objects returned from the evaluated rules, including any
+   *     errors.
+   */
+  public Collection<SubmitRecord> evaluate() {
+    Change change;
+    try {
+      change = cd.change();
+      if (change == null) {
+        throw new OrmException("No change found");
+      }
+
+      if (projectState == null) {
+        throw new NoSuchProjectException(cd.project());
+      }
+    } catch (OrmException | NoSuchProjectException e) {
+      return ruleError("Error looking up change " + cd.getId(), e);
+    }
+
+    if (!opts.allowClosed() && change.getStatus().isClosed()) {
+      SubmitRecord rec = new SubmitRecord();
+      rec.status = SubmitRecord.Status.CLOSED;
+      return Collections.singletonList(rec);
+    }
+
+    List<Term> results;
+    try {
+      results =
+          evaluateImpl(
+              "locate_submit_rule", "can_submit", "locate_submit_filter", "filter_submit_results");
+    } catch (RuleEvalException e) {
+      return ruleError(e.getMessage(), e);
+    }
+
+    if (results.isEmpty()) {
+      // This should never occur. A well written submit rule will always produce
+      // at least one result informing the caller of the labels that are
+      // required for this change to be submittable. Each label will indicate
+      // whether or not that is actually possible given the permissions.
+      return ruleError(
+          String.format(
+              "Submit rule '%s' for change %s of %s has no solution.",
+              getSubmitRuleName(), cd.getId(), projectState.getName()));
+    }
+
+    return resultsToSubmitRecord(getSubmitRule(), results);
+  }
+
+  private String getSubmitRuleName() {
+    return submitRule == null ? "<unknown>" : submitRule.name();
+  }
+
+  /**
+   * Convert the results from Prolog Cafe's format to Gerrit's common format.
+   *
+   * <p>can_submit/1 terminates when an ok(P) record is found. Therefore walk the results backwards,
+   * using only that ok(P) record if it exists. This skips partial results that occur early in the
+   * output. Later after the loop the out collection is reversed to restore it to the original
+   * ordering.
+   */
+  public List<SubmitRecord> resultsToSubmitRecord(Term submitRule, List<Term> results) {
+    boolean foundOk = false;
+    List<SubmitRecord> out = new ArrayList<>(results.size());
+    for (int resultIdx = results.size() - 1; 0 <= resultIdx; resultIdx--) {
+      Term submitRecord = results.get(resultIdx);
+      SubmitRecord rec = new SubmitRecord();
+      out.add(rec);
+
+      if (!(submitRecord instanceof StructureTerm) || 1 != submitRecord.arity()) {
+        return invalidResult(submitRule, submitRecord);
+      }
+
+      if ("ok".equals(submitRecord.name())) {
+        rec.status = SubmitRecord.Status.OK;
+
+      } else if ("not_ready".equals(submitRecord.name())) {
+        rec.status = SubmitRecord.Status.NOT_READY;
+
+      } else {
+        return invalidResult(submitRule, submitRecord);
+      }
+
+      // Unpack the one argument. This should also be a structure with one
+      // argument per label that needs to be reported on to the caller.
+      //
+      submitRecord = submitRecord.arg(0);
+
+      if (!(submitRecord instanceof StructureTerm)) {
+        return invalidResult(submitRule, submitRecord);
+      }
+
+      rec.labels = new ArrayList<>(submitRecord.arity());
+
+      for (Term state : ((StructureTerm) submitRecord).args()) {
+        if (!(state instanceof StructureTerm)
+            || 2 != state.arity()
+            || !"label".equals(state.name())) {
+          return invalidResult(submitRule, submitRecord);
+        }
+
+        SubmitRecord.Label lbl = new SubmitRecord.Label();
+        rec.labels.add(lbl);
+
+        lbl.label = state.arg(0).name();
+        Term status = state.arg(1);
+
+        try {
+          if ("ok".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.OK;
+            appliedBy(lbl, status);
+
+          } else if ("reject".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.REJECT;
+            appliedBy(lbl, status);
+
+          } else if ("need".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.NEED;
+
+          } else if ("may".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.MAY;
+
+          } else if ("impossible".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.IMPOSSIBLE;
+
+          } else {
+            return invalidResult(submitRule, submitRecord);
+          }
+        } catch (UserTermExpected e) {
+          return invalidResult(submitRule, submitRecord, e.getMessage());
+        }
+      }
+
+      if (rec.status == SubmitRecord.Status.OK) {
+        foundOk = true;
+        break;
+      }
+    }
+    Collections.reverse(out);
+
+    // This transformation is required to adapt Prolog's behavior to the way Gerrit handles
+    // SubmitRecords, as defined in the SubmitRecord#allRecordsOK method.
+    // When several rules are defined in Prolog, they are all matched to a SubmitRecord. We want
+    // the change to be submittable when at least one result is OK.
+    if (foundOk) {
+      for (SubmitRecord record : out) {
+        record.status = SubmitRecord.Status.OK;
+      }
+    }
+
+    return out;
+  }
+
+  private List<SubmitRecord> invalidResult(Term rule, Term record, String reason) {
+    return ruleError(
+        String.format(
+            "Submit rule %s for change %s of %s output invalid result: %s%s",
+            rule,
+            cd.getId(),
+            cd.project().get(),
+            record,
+            (reason == null ? "" : ". Reason: " + reason)));
+  }
+
+  private List<SubmitRecord> invalidResult(Term rule, Term record) {
+    return invalidResult(rule, record, null);
+  }
+
+  private List<SubmitRecord> ruleError(String err) {
+    return ruleError(err, null);
+  }
+
+  private List<SubmitRecord> ruleError(String err, Exception e) {
+    if (opts.logErrors()) {
+      if (e == null) {
+        log.error(err);
+      } else {
+        log.error(err, e);
+      }
+      return defaultRuleError();
+    }
+    return createRuleError(err);
+  }
+
+  /**
+   * Evaluate the submit type rules to get the submit type.
+   *
+   * @return record from the evaluated rules.
+   */
+  public SubmitTypeRecord getSubmitType() {
+    try {
+      if (projectState == null) {
+        throw new NoSuchProjectException(cd.project());
+      }
+    } catch (NoSuchProjectException e) {
+      return typeError("Error looking up change " + cd.getId(), e);
+    }
+
+    List<Term> results;
+    try {
+      results =
+          evaluateImpl(
+              "locate_submit_type",
+              "get_submit_type",
+              "locate_submit_type_filter",
+              "filter_submit_type_results");
+    } catch (RuleEvalException e) {
+      return typeError(e.getMessage(), e);
+    }
+
+    if (results.isEmpty()) {
+      // Should never occur for a well written rule
+      return typeError(
+          "Submit rule '"
+              + getSubmitRuleName()
+              + "' for change "
+              + cd.getId()
+              + " of "
+              + projectState.getName()
+              + " has no solution.");
+    }
+
+    Term typeTerm = results.get(0);
+    if (!(typeTerm instanceof SymbolTerm)) {
+      return typeError(
+          "Submit rule '"
+              + getSubmitRuleName()
+              + "' for change "
+              + cd.getId()
+              + " of "
+              + projectState.getName()
+              + " did not return a symbol.");
+    }
+
+    String typeName = typeTerm.name();
+    try {
+      return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase()));
+    } catch (IllegalArgumentException e) {
+      return typeError(
+          "Submit type rule "
+              + getSubmitRule()
+              + " for change "
+              + cd.getId()
+              + " of "
+              + projectState.getName()
+              + " output invalid result: "
+              + typeName);
+    }
+  }
+
+  private SubmitTypeRecord typeError(String err) {
+    return typeError(err, null);
+  }
+
+  private SubmitTypeRecord typeError(String err, Exception e) {
+    if (opts.logErrors()) {
+      if (e == null) {
+        log.error(err);
+      } else {
+        log.error(err, e);
+      }
+      return defaultTypeError();
+    }
+    return SubmitTypeRecord.error(err);
+  }
+
+  private List<Term> evaluateImpl(
+      String userRuleLocatorName,
+      String userRuleWrapperName,
+      String filterRuleLocatorName,
+      String filterRuleWrapperName)
+      throws RuleEvalException {
+    PrologEnvironment env = getPrologEnvironment();
+    try {
+      Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm());
+      List<Term> results = new ArrayList<>();
+      try {
+        for (Term[] template : env.all("gerrit", userRuleWrapperName, sr, new VariableTerm())) {
+          results.add(template[1]);
+        }
+      } catch (ReductionLimitException err) {
+        throw new RuleEvalException(
+            String.format(
+                "%s on change %d of %s",
+                err.getMessage(), cd.getId().get(), projectState.getName()));
+      } catch (RuntimeException err) {
+        throw new RuleEvalException(
+            String.format(
+                "Exception calling %s on change %d of %s",
+                sr, cd.getId().get(), projectState.getName()),
+            err);
+      }
+
+      Term resultsTerm = toListTerm(results);
+      if (!opts.skipFilters()) {
+        resultsTerm =
+            runSubmitFilters(resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName);
+      }
+      List<Term> r;
+      if (resultsTerm instanceof ListTerm) {
+        r = new ArrayList<>();
+        for (Term t = resultsTerm; t instanceof ListTerm; ) {
+          ListTerm l = (ListTerm) t;
+          r.add(l.car().dereference());
+          t = l.cdr().dereference();
+        }
+      } else {
+        r = Collections.emptyList();
+      }
+      submitRule = sr;
+      return r;
+    } finally {
+      env.close();
+    }
+  }
+
+  private PrologEnvironment getPrologEnvironment() throws RuleEvalException {
+    PrologEnvironment env;
+    try {
+      if (opts.rule() == null) {
+        env = projectState.newPrologEnvironment();
+      } else {
+        env = projectState.newPrologEnvironment("stdin", new StringReader(opts.rule()));
+      }
+    } catch (CompileException err) {
+      String msg;
+      if (opts.rule() == null) {
+        msg =
+            String.format(
+                "Cannot load rules.pl for %s: %s", projectState.getName(), err.getMessage());
+      } else {
+        msg = err.getMessage();
+      }
+      throw new RuleEvalException(msg, err);
+    }
+    env.set(StoredValues.ACCOUNTS, accounts);
+    env.set(StoredValues.ACCOUNT_CACHE, accountCache);
+    env.set(StoredValues.EMAILS, emails);
+    env.set(StoredValues.REVIEW_DB, cd.db());
+    env.set(StoredValues.CHANGE_DATA, cd);
+    env.set(StoredValues.PROJECT_STATE, projectState);
+    return env;
+  }
+
+  private Term runSubmitFilters(
+      Term results,
+      PrologEnvironment env,
+      String filterRuleLocatorName,
+      String filterRuleWrapperName)
+      throws RuleEvalException {
+    PrologEnvironment childEnv = env;
+    ChangeData cd = env.get(StoredValues.CHANGE_DATA);
+    ProjectState projectState = env.get(StoredValues.PROJECT_STATE);
+    for (ProjectState parentState : projectState.parents()) {
+      PrologEnvironment parentEnv;
+      try {
+        parentEnv = parentState.newPrologEnvironment();
+      } catch (CompileException err) {
+        throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
+      }
+
+      parentEnv.copyStoredValues(childEnv);
+      Term filterRule = parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm());
+      try {
+        Term[] template =
+            parentEnv.once(
+                "gerrit", filterRuleWrapperName, filterRule, results, new VariableTerm());
+        results = template[2];
+      } catch (ReductionLimitException err) {
+        throw new RuleEvalException(
+            String.format(
+                "%s on change %d of %s",
+                err.getMessage(), cd.getId().get(), parentState.getName()));
+      } catch (RuntimeException err) {
+        throw new RuleEvalException(
+            String.format(
+                "Exception calling %s on change %d of %s",
+                filterRule, cd.getId().get(), parentState.getName()),
+            err);
+      }
+      childEnv = parentEnv;
+    }
+    return results;
+  }
+
+  private void appliedBy(SubmitRecord.Label label, Term status) throws UserTermExpected {
+    if (status instanceof StructureTerm && status.arity() == 1) {
+      Term who = status.arg(0);
+      if (isUser(who)) {
+        label.appliedBy = new Account.Id(((IntegerTerm) who.arg(0)).intValue());
+      } else {
+        throw new UserTermExpected(label);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/SubmitRule.java b/java/com/google/gerrit/server/rules/SubmitRule.java
new file mode 100644
index 0000000..2a68683
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/SubmitRule.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2018 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.rules;
+
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+
+/**
+ * Allows plugins to decide whether a change is ready to be submitted or not.
+ *
+ * <p>For a given {@link ChangeData}, each plugin is called and returns a {@link Collection} of
+ * {@link SubmitRecord}. This collection can be empty, or contain one or several values.
+ *
+ * <p>A Change can only be submitted if all the plugins give their consent.
+ *
+ * <p>Each {@link SubmitRecord} represents a decision made by the plugin. If the plugin rejects a
+ * change, it should hold valuable informations to help the end user understand and correct the
+ * blocking points.
+ *
+ * <p>It should be noted that each plugin can handle rules inheritance.
+ *
+ * <p>This interface should be used to write pre-submit validation rules. This includes both simple
+ * checks, coded in Java, and more complex fully fledged expression evaluators (think: Prolog,
+ * JavaCC, or even JavaScript rules).
+ */
+@ExtensionPoint
+public interface SubmitRule {
+  /** Returns a {@link Collection} of {@link SubmitRecord} status for the change. */
+  Collection<SubmitRecord> evaluate(ChangeData changeData, SubmitRuleOptions options);
+}
diff --git a/java/org/eclipse/jgit/BUILD b/java/org/eclipse/jgit/BUILD
index 65bc118..b9b807a 100644
--- a/java/org/eclipse/jgit/BUILD
+++ b/java/org/eclipse/jgit/BUILD
@@ -39,7 +39,6 @@
     srcs = [
         "diff/EditDeserializer.java",
         "diff/ReplaceEdit.java",
-        "internal/storage/file/WindowCacheStatAccessor.java",
         "lib/ObjectIdSerialization.java",
     ],
     visibility = ["//visibility:public"],
diff --git a/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java b/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java
deleted file mode 100644
index f8c4340..0000000
--- a/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2009 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 org.eclipse.jgit.internal.storage.file;
-
-// Hack to obtain visibility to package level methods only.
-// These aren't yet part of the public JGit API.
-
-public class WindowCacheStatAccessor {
-  public static int getOpenFiles() {
-    return WindowCache.getInstance().getOpenFiles();
-  }
-
-  public static long getOpenBytes() {
-    return WindowCache.getInstance().getOpenBytes();
-  }
-
-  private WindowCacheStatAccessor() {}
-}
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 6c3a4b0..74b23a4 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -71,7 +71,8 @@
   public void resetAllRefs() throws Exception {
     Ref matchingRef = createRef("refs/any/test");
 
-    try (ProjectResetter resetProject = builder().reset(project).build()) {
+    try (ProjectResetter resetProject =
+        builder().build(new ProjectResetter.Config().reset(project))) {
       updateRef(matchingRef);
     }
 
@@ -87,7 +88,10 @@
 
     Ref updatedNonMatchingRef;
     try (ProjectResetter resetProject =
-        builder().reset(project, "refs/match/*", "refs/another-match/*").build()) {
+        builder()
+            .build(
+                new ProjectResetter.Config()
+                    .reset(project, "refs/match/*", "refs/another-match/*"))) {
       updateRef(matchingRef);
       updateRef(anotherMatchingRef);
       updatedNonMatchingRef = updateRef(nonMatchingRef);
@@ -107,7 +111,10 @@
     Ref anotherMatchingRef;
     Ref nonMatchingRef;
     try (ProjectResetter resetProject =
-        builder().reset(project, "refs/match/*", "refs/another-match/*").build()) {
+        builder()
+            .build(
+                new ProjectResetter.Config()
+                    .reset(project, "refs/match/*", "refs/another-match/*"))) {
       matchingRef = createRef("refs/match/test");
       anotherMatchingRef = createRef("refs/another-match/test");
       nonMatchingRef = createRef("refs/no-match/test");
@@ -135,7 +142,11 @@
     Ref updatedNonMatchingRefProject1;
     Ref updatedNonMatchingRefProject2;
     try (ProjectResetter resetProject =
-        builder().reset(project, "refs/foo/*").reset(project2, "refs/bar/*").build()) {
+        builder()
+            .build(
+                new ProjectResetter.Config()
+                    .reset(project, "refs/foo/*")
+                    .reset(project2, "refs/bar/*"))) {
       updateRef(matchingRefProject1);
       updatedNonMatchingRefProject1 = updateRef(nonMatchingRefProject1);
 
@@ -162,7 +173,11 @@
     Ref matchingRefProject2;
     Ref nonMatchingRefProject2;
     try (ProjectResetter resetProject =
-        builder().reset(project, "refs/foo/*").reset(project2, "refs/bar/*").build()) {
+        builder()
+            .build(
+                new ProjectResetter.Config()
+                    .reset(project, "refs/foo/*")
+                    .reset(project2, "refs/bar/*"))) {
       matchingRefProject1 = createRef("refs/foo/test");
       nonMatchingRefProject1 = createRef("refs/bar/test");
 
@@ -183,7 +198,9 @@
   public void onlyDeleteNewlyCreatedWithOverlappingRefPatterns() throws Exception {
     Ref matchingRef;
     try (ProjectResetter resetProject =
-        builder().reset(project, "refs/match/*", "refs/match/test").build()) {
+        builder()
+            .build(
+                new ProjectResetter.Config().reset(project, "refs/match/*", "refs/match/test"))) {
       // This ref matches 2 ref pattern, ProjectResetter should try to delete it only once.
       matchingRef = createRef("refs/match/test");
     }
@@ -206,7 +223,8 @@
     Ref nonMetaConfig = createRef("refs/heads/master");
 
     try (ProjectResetter resetProject =
-        builder(null, null, projectCache).reset(project).reset(project2).build()) {
+        builder(null, null, projectCache)
+            .build(new ProjectResetter.Config().reset(project).reset(project2))) {
       updateRef(nonMetaConfig);
       updateRef(repo2, metaConfig);
     }
@@ -225,7 +243,8 @@
     EasyMock.replay(projectCache);
 
     try (ProjectResetter resetProject =
-        builder(null, null, projectCache).reset(project).reset(project2).build()) {
+        builder(null, null, projectCache)
+            .build(new ProjectResetter.Config().reset(project).reset(project2))) {
       createRef("refs/heads/master");
       createRef(repo2, RefNames.REFS_CONFIG);
     }
@@ -249,7 +268,8 @@
     Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(2)));
 
     try (ProjectResetter resetProject =
-        builder(null, accountCache, null).reset(project).reset(allUsers).build()) {
+        builder(null, accountCache, null)
+            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       updateRef(nonUserBranch);
       updateRef(allUsersRepo, userBranch);
     }
@@ -269,7 +289,8 @@
     EasyMock.replay(accountCache);
 
     try (ProjectResetter resetProject =
-        builder(null, accountCache, null).reset(project).reset(allUsers).build()) {
+        builder(null, accountCache, null)
+            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       // Non-user branch because it's not in All-Users.
       createRef(RefNames.refsUsers(new Account.Id(2)));
 
@@ -300,7 +321,8 @@
     Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
 
     try (ProjectResetter resetProject =
-        builder(null, accountCache, null).reset(project).reset(allUsers).build()) {
+        builder(null, accountCache, null)
+            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       updateRef(nonUserBranch);
       updateRef(allUsersRepo, externalIds);
       createRef(allUsersRepo, RefNames.refsUsers(accountId2));
@@ -329,7 +351,8 @@
     Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
 
     try (ProjectResetter resetProject =
-        builder(null, accountCache, null).reset(project).reset(allUsers).build()) {
+        builder(null, accountCache, null)
+            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       updateRef(nonUserBranch);
       createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
       createRef(allUsersRepo, RefNames.refsUsers(accountId2));
@@ -350,7 +373,8 @@
     EasyMock.replay(accountCreator);
 
     try (ProjectResetter resetProject =
-        builder(accountCreator, null, null).reset(project).reset(allUsers).build()) {
+        builder(accountCreator, null, null)
+            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       createRef(allUsersRepo, RefNames.refsUsers(accountId));
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 8f27de3..eb40513 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -80,6 +80,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -168,7 +169,6 @@
 import org.eclipse.jgit.transport.PushResult;
 import org.junit.After;
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Test;
 
 @NoHttpd
@@ -675,6 +675,37 @@
   }
 
   @Test
+  public void revertNotifications() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    sender.clear();
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(2);
+    assertThat(sender.getMessages(revertChange.changeId, "newchange")).hasSize(1);
+    assertThat(sender.getMessages(r.getChangeId(), "revert")).hasSize(1);
+  }
+
+  @Test
+  public void suppressRevertNotifications() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.NONE;
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revert(revertInput).get();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void revertPreservesReviewersAndCcs() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -1506,7 +1537,6 @@
     assertThat(sender.getMessages()).hasSize(1);
   }
 
-  @Ignore
   @Test
   public void addReviewerThatIsNotPerfectMatch() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -1551,7 +1581,6 @@
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
-  @Ignore
   @Test
   public void addGroupAsReviewersWhenANotPerfectMatchedUserExists() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -1560,16 +1589,17 @@
     String oldETag = rsrc.getETag();
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
-    //create a group named "us" with one user: testUser
-    TestAccount testUser = accountCreator.create("testUser", "testUser@test.com", "testUser");
-    String testGroup =
-        createGroupWithRealName(user.fullName.substring(0, user.fullName.length() / 2));
+    //create a group named "kobe" with one user: lee
+    TestAccount testUser = accountCreator.create("kobebryant", "kobebryant@test.com", "kobebryant");
+    TestAccount myGroupUser = accountCreator.create("lee", "lee@test.com", "lee");
+
+    String testGroup = createGroupWithRealName("kobe");
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
-    groupApi.addMembers(testUser.fullName);
+    groupApi.addMembers(myGroupUser.fullName);
 
     //ensure that user "user" is not in the group
-    groupApi.removeMembers(user.fullName);
+    groupApi.removeMembers(testUser.fullName);
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = testGroup;
@@ -1578,11 +1608,11 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(testUser.emailAddress);
-    assertThat(m.body()).contains("Hello " + testUser.fullName + ",\n");
+    assertThat(m.rcpt()).containsExactly(myGroupUser.emailAddress);
+    assertThat(m.body()).contains("Hello " + myGroupUser.fullName + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, testUser.email);
+    assertMailReplyTo(m, myGroupUser.email);
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     // When NoteDb is enabled adding a reviewer records that user as reviewer
@@ -1592,7 +1622,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(testUser.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(myGroupUser.getId().get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 2d5703d..32dc5dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -40,7 +40,6 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.ProjectResetter;
-import com.google.gerrit.acceptance.ProjectResetter.Builder;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
@@ -166,11 +165,11 @@
   }
 
   @Override
-  protected ProjectResetter resetProjects(Builder resetter) throws IOException {
+  protected ProjectResetter.Config resetProjects() {
     // Don't reset All-Users since deleting users makes groups inconsistent (e.g. groups would
     // contain members that no longer exist) and as result of this the group consistency checker
     // that is executed after each test would fail.
-    return resetter.reset(allProjects, RefNames.REFS_CONFIG).build();
+    return new ProjectResetter.Config().reset(allProjects, RefNames.REFS_CONFIG);
   }
 
   @Test
@@ -1076,7 +1075,9 @@
 
     // Use ProjectResetter to restore the group names ref
     try (ProjectResetter resetter =
-        projectResetter.builder().reset(allUsers, RefNames.REFS_GROUPNAMES).build()) {
+        projectResetter
+            .builder()
+            .build(new ProjectResetter.Config().reset(allUsers, RefNames.REFS_GROUPNAMES))) {
       // Manually delete group names ref
       try (Repository repo = repoManager.openRepository(allUsers);
           RevWalk rw = new RevWalk(repo)) {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index c986c5e..84cfb0d 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -158,6 +158,38 @@
   }
 
   @Test
+  public void createProjectWithNonExistingParent() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name("baz");
+    in.parent = "non-existing";
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Project Not Found: " + in.parent);
+    gApi.projects().create(in);
+  }
+
+  @Test
+  public void createProjectWithSelfAsParentNotPossible() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name("baz");
+    in.parent = in.name;
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Project Not Found: " + in.parent);
+    gApi.projects().create(in);
+  }
+
+  @Test
+  public void createProjectUnderAllUsersNotAllowed() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name("foo");
+    in.parent = allUsers.get();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(String.format("Cannot inherit from '%s' project", allUsers.get()));
+    gApi.projects().create(in);
+  }
+
+  @Test
   public void createAndDeleteBranch() throws Exception {
     assertThat(getRemoteHead(project.get(), "foo")).isNull();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index c1bc83e..ec4f327 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -90,6 +90,13 @@
   }
 
   @Test
+  public void setParentToAllUsersNotAllowed() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(String.format("Cannot inherit from '%s' project", allUsers.get()));
+    gApi.projects().name(project.get()).parent(allUsers.get());
+  }
+
+  @Test
   public void setParentForAllUsersMustBeAllProjects() throws Exception {
     gApi.projects().name(allUsers.get()).parent(allProjects.get());
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index e0c16e0f..2c95e27 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -785,7 +785,8 @@
   private ProjectResetter resetGroups() throws IOException {
     return projectResetter
         .builder()
-        .reset(allUsers, RefNames.REFS_GROUPS + "*", RefNames.REFS_GROUPNAMES)
-        .build();
+        .build(
+            new ProjectResetter.Config()
+                .reset(allUsers, RefNames.REFS_GROUPS + "*", RefNames.REFS_GROUPNAMES));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 7583830..2812c86 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -346,17 +346,17 @@
           "master",
           "Update git submodules\n\n"
               + "* Update "
-              + name("sub3")
+              + name("sub1")
               + " from branch 'master'\n  to "
-              + sub3HEAD
+              + sub1HEAD
               + "\n\n* Update "
               + name("sub2")
               + " from branch 'master'\n  to "
               + sub2HEAD
               + "\n\n* Update "
-              + name("sub1")
+              + name("sub3")
               + " from branch 'master'\n  to "
-              + sub1HEAD);
+              + sub3HEAD);
     }
 
     superRepo
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index c1bbf2e..c78b47b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.ProjectState;
+import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
@@ -114,4 +115,67 @@
 
     assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
   }
+
+  @Test
+  public void withMergedInheritance() throws Exception {
+    String configName = "test.config";
+
+    Config parentCfg = new Config();
+    parentCfg.setString("s1", null, "k1", "parentValue1");
+    parentCfg.setString("s1", null, "k2", "parentValue2");
+    parentCfg.setString("s2", "ss", "k3", "parentValue3");
+    parentCfg.setString("s2", "ss", "k4", "parentValue4");
+
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Create Project Level Config",
+            configName,
+            parentCfg.toText())
+        .to(RefNames.REFS_CONFIG)
+        .assertOkStatus();
+
+    Project.NameKey childProject = createProject("child", project);
+    TestRepository<?> childTestRepo = cloneProject(childProject);
+    fetch(childTestRepo, RefNames.REFS_CONFIG + ":refs/heads/config");
+    childTestRepo.reset("refs/heads/config");
+
+    Config cfg = new Config();
+    cfg.setString("s1", null, "k1", "parentValue1");
+    cfg.setString("s1", null, "k2", "parentValue2");
+    cfg.setString("s2", "ss", "k3", "parentValue3");
+    cfg.setString("s2", "ss", "k4", "parentValue4");
+    cfg.setString("s1", null, "k1", "childValue1");
+    cfg.setString("s2", "ss", "k3", "childValue2");
+    cfg.setString("s3", null, "k5", "childValue3");
+    cfg.setString("s3", "ss", "k6", "childValue4");
+
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            childTestRepo,
+            "Create Project Level Config",
+            configName,
+            cfg.toText())
+        .to(RefNames.REFS_CONFIG)
+        .assertOkStatus();
+
+    ProjectState state = projectCache.get(childProject);
+
+    Config expectedCfg = new Config();
+    expectedCfg.setStringList("s1", null, "k1", Arrays.asList("childValue1", "parentValue1"));
+    expectedCfg.setString("s1", null, "k2", "parentValue2");
+    expectedCfg.setStringList("s2", "ss", "k3", Arrays.asList("childValue2", "parentValue3"));
+    expectedCfg.setString("s2", "ss", "k4", "parentValue4");
+    expectedCfg.setString("s3", null, "k5", "childValue3");
+    expectedCfg.setString("s3", "ss", "k6", "childValue4");
+
+    assertThat(state.getConfig(configName).getWithInheritance(true).toText())
+        .isEqualTo(expectedCfg.toText());
+
+    assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 417c9e3..a6c1153 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -2219,17 +2219,22 @@
     assume().that(notesMigration.readChanges()).isFalse();
     StagedChange sc = stageChange();
     revert(sc, sc.owner);
+
+    // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
         .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
 
+    // email for the change that is reverted
     assertThat(sender)
         .sent("revert", sc)
         .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail)
+        .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
   }
 
   @Test
@@ -2237,18 +2242,23 @@
     assume().that(notesMigration.readChanges()).isTrue();
     StagedChange sc = stageChange();
     revert(sc, sc.owner);
+
+    // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
         .to(sc.reviewer, sc.watchingProjectOwner, admin)
         .cc(sc.ccer)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
 
+    // email for the change that is reverted
     assertThat(sender)
         .sent("revert", sc)
         .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
   }
 
   @Test
@@ -2256,19 +2266,24 @@
     assume().that(notesMigration.readChanges()).isFalse();
     StagedChange sc = stageChange();
     revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
+
+    // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
         .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
         .cc(sc.owner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
 
+    // email for the change that is reverted
     assertThat(sender)
         .sent("revert", sc)
         .to(sc.owner)
         .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail)
+        .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
   }
 
   @Test
@@ -2276,19 +2291,24 @@
     assume().that(notesMigration.readChanges()).isTrue();
     StagedChange sc = stageChange();
     revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
+
+    // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
         .to(sc.reviewer, sc.watchingProjectOwner, admin)
         .cc(sc.owner, sc.ccer)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
 
+    // email for the change that is reverted
     assertThat(sender)
         .sent("revert", sc)
         .to(sc.owner)
         .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
   }
 
   @Test
@@ -2296,17 +2316,23 @@
     assume().that(notesMigration.readChanges()).isFalse();
     StagedChange sc = stageChange();
     revert(sc, other);
+
+    // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
         .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
 
+    // email for the change that is reverted
     assertThat(sender)
         .sent("revert", sc)
-        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail)
+        .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
   }
 
   @Test
@@ -2314,18 +2340,24 @@
     assume().that(notesMigration.readChanges()).isTrue();
     StagedChange sc = stageChange();
     revert(sc, other);
+
+    // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
         .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
         .cc(sc.ccer)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
 
+    // email for the change that is reverted
     assertThat(sender)
         .sent("revert", sc)
-        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
   }
 
   @Test
@@ -2333,19 +2365,24 @@
     assume().that(notesMigration.readChanges()).isFalse();
     StagedChange sc = stageChange();
     revert(sc, other, CC_ON_OWN_COMMENTS);
+
+    // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
         .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
         .cc(other)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
 
+    // email for the change that is reverted
     assertThat(sender)
         .sent("revert", sc)
-        .to(other)
-        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
+        .to(sc.owner)
+        .cc(other, sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail)
+        .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
   }
 
   @Test
@@ -2353,19 +2390,24 @@
     assume().that(notesMigration.readChanges()).isTrue();
     StagedChange sc = stageChange();
     revert(sc, other, CC_ON_OWN_COMMENTS);
+
+    // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
         .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
         .cc(sc.ccer, other)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
 
+    // email for the change that is reverted
     assertThat(sender)
         .sent("revert", sc)
-        .to(other)
-        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
+        .to(sc.owner)
+        .cc(other, sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
         .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+        .noOneElse();
   }
 
   private StagedChange stageChange() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/BUILD b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
new file mode 100644
index 0000000..2e96c0b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
@@ -0,0 +1,10 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_rules",
+    labels = ["server"],
+    deps = [
+        "@prolog_runtime//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
new file mode 100644
index 0000000..8fc32b4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2018 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.acceptance.server.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.PrologRuleEvaluator;
+import com.google.gerrit.testing.TestChanges;
+import com.google.inject.Inject;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.junit.Test;
+
+public class PrologRuleEvaluatorIT extends AbstractDaemonTest {
+  @Inject private PrologRuleEvaluator.Factory evaluatorFactory;
+
+  @Test
+  public void convertsPrologToSubmitRecord() {
+    PrologRuleEvaluator evaluator = makeEvaluator();
+
+    StructureTerm verifiedLabel = makeLabel("Verified", "may");
+    StructureTerm labels = new StructureTerm("label", verifiedLabel);
+
+    List<Term> terms = ImmutableList.of(makeTerm("ok", labels));
+    Collection<SubmitRecord> records = evaluator.resultsToSubmitRecord(null, terms);
+
+    assertThat(records).hasSize(1);
+  }
+
+  /**
+   * The Prolog behavior is everything but intuitive. Several submit_rules can be defined, and each
+   * will provide a different SubmitRecord answer when queried. The current implementation stops
+   * parsing the Prolog terms into SubmitRecord objects once it finds an OK record. This might lead
+   * to tangling results, as reproduced by this test.
+   *
+   * <p>Let's consider this rules.pl file (equivalent to the code in this test)
+   *
+   * <pre>{@code
+   * submit_rule(submit(R)) :-
+   *     gerrit:uploader(U),
+   *     R = label('Verified', reject(U)).
+   *
+   * submit_rule(submit(CR, V)) :-
+   *     gerrit:uploader(U),
+   *     V = label('Code-Review', ok(U)).
+   *
+   * submit_rule(submit(R)) :-
+   *     gerrit:uploader(U),
+   *     R = label('Any-Label-Name', reject(U)).
+   * }</pre>
+   *
+   * The first submit_rule always fails because the Verified label is rejected.
+   *
+   * <p>The second submit_rule is always valid, and provides two labels: OK and Code-Review.
+   *
+   * <p>The third submit_rule always fails because the Any-Label-Name label is rejected.
+   *
+   * <p>In this case, the last two SubmitRecords are used, the first one is discarded.
+   */
+  @Test
+  public void abortsEarlyWithOkayRecord() {
+    PrologRuleEvaluator evaluator = makeEvaluator();
+
+    SubmitRecord.Label submitRecordLabel1 = new SubmitRecord.Label();
+    submitRecordLabel1.label = "Verified";
+    submitRecordLabel1.status = SubmitRecord.Label.Status.REJECT;
+    submitRecordLabel1.appliedBy = admin.id;
+
+    SubmitRecord.Label submitRecordLabel2 = new SubmitRecord.Label();
+    submitRecordLabel2.label = "Code-Review";
+    submitRecordLabel2.status = SubmitRecord.Label.Status.OK;
+    submitRecordLabel2.appliedBy = admin.id;
+
+    SubmitRecord.Label submitRecordLabel3 = new SubmitRecord.Label();
+    submitRecordLabel3.label = "Any-Label-Name";
+    submitRecordLabel3.status = SubmitRecord.Label.Status.REJECT;
+    submitRecordLabel3.appliedBy = user.id;
+
+    List<Term> terms = new ArrayList<>();
+
+    StructureTerm label1 = makeLabel(submitRecordLabel1.label, "reject", admin);
+
+    StructureTerm label2 = makeLabel(submitRecordLabel2.label, "ok", admin);
+
+    StructureTerm label3 = makeLabel(submitRecordLabel3.label, "reject", user);
+
+    terms.add(makeTerm("not_ready", makeLabels(label1)));
+    terms.add(makeTerm("ok", makeLabels(label2)));
+    terms.add(makeTerm("not_ready", makeLabels(label3)));
+
+    // When
+    List<SubmitRecord> records = evaluator.resultsToSubmitRecord(null, terms);
+
+    // assert that
+    SubmitRecord record1Expected = new SubmitRecord();
+    record1Expected.status = SubmitRecord.Status.OK;
+    record1Expected.labels = new ArrayList<>();
+    record1Expected.labels.add(submitRecordLabel2);
+
+    SubmitRecord record2Expected = new SubmitRecord();
+    record2Expected.status = SubmitRecord.Status.OK;
+    record2Expected.labels = new ArrayList<>();
+    record2Expected.labels.add(submitRecordLabel3);
+
+    assertThat(records).hasSize(2);
+
+    assertThat(records.get(0)).isEqualTo(record1Expected);
+    assertThat(records.get(1)).isEqualTo(record2Expected);
+  }
+
+  private static Term makeTerm(String status, StructureTerm labels) {
+    return new StructureTerm(status, labels);
+  }
+
+  private static StructureTerm makeLabel(String name, String status) {
+    return new StructureTerm("label", new StructureTerm(name), new StructureTerm(status));
+  }
+
+  private static StructureTerm makeLabel(String name, String status, TestAccount account) {
+    StructureTerm user = new StructureTerm("user", new IntegerTerm(account.id.get()));
+    return new StructureTerm("label", new StructureTerm(name), new StructureTerm(status, user));
+  }
+
+  private static StructureTerm makeLabels(StructureTerm... labels) {
+    return new StructureTerm("label", labels);
+  }
+
+  private ChangeData makeChangeData() {
+    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
+    cd.setChange(TestChanges.newChange(project, admin.id));
+    return cd;
+  }
+
+  private PrologRuleEvaluator makeEvaluator() {
+    return evaluatorFactory.create(makeChangeData(), SubmitRuleOptions.defaults());
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
new file mode 100644
index 0000000..6a90f45
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2018 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.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import org.junit.Test;
+
+public class SubmitRecordTest {
+
+  private static SubmitRecord OK_RECORD;
+  private static SubmitRecord NOT_READY_RECORD;
+
+  static {
+    OK_RECORD = new SubmitRecord();
+    OK_RECORD.status = SubmitRecord.Status.OK;
+
+    NOT_READY_RECORD = new SubmitRecord();
+    NOT_READY_RECORD.status = SubmitRecord.Status.NOT_READY;
+  }
+
+  @Test
+  public void okIfAllOkay() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(OK_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
+  public void okWhenEmpty() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
+  public void emptyResultIfInvalid() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(NOT_READY_RECORD);
+    submitRecords.add(OK_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 031284d..95ac3e3 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.ReviewerSet;
@@ -30,6 +31,7 @@
 import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.sql.Timestamp;
+import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 import org.junit.After;
@@ -80,11 +82,32 @@
   @Test
   public void storedSubmitRecords() {
     assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
-    assertStoredRecordRoundTrip(
+
+    SubmitRecord r =
         record(
             SubmitRecord.Status.OK,
             label(SubmitRecord.Label.Status.MAY, "Label-1", null),
-            label(SubmitRecord.Label.Status.OK, "Label-2", 1)));
+            label(SubmitRecord.Label.Status.OK, "Label-2", 1));
+
+    assertStoredRecordRoundTrip(r);
+  }
+
+  @Test
+  public void storedSubmitRecordsWithRequirements() {
+    SubmitRecord r =
+        record(
+            SubmitRecord.Status.OK,
+            label(SubmitRecord.Label.Status.MAY, "Label-1", null),
+            label(SubmitRecord.Label.Status.OK, "Label-2", 1));
+
+    SubmitRequirement sr =
+        new SubmitRequirement(
+            "short reason",
+            "Full reason can be a long string with special symbols like < > \\ / ; :",
+            null);
+    r.requirements = Collections.singletonList(sr);
+
+    assertStoredRecordRoundTrip(r);
   }
 
   private static SubmitRecord record(SubmitRecord.Status status, SubmitRecord.Label... labels) {
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 7bee9e34..71b91a5 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,12 +1,12 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.10.0.201712302008-r.24-gf3bb0e268"
+_JGIT_VERS = "4.11.0.201803080745-r"
 
-_DOC_VERS = "4.10.0.201712302008-r"  # Set to _JGIT_VERS unless using a snapshot
+_DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
 
 JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
-_JGIT_REPO = GERRIT  # Leave here even if set to MAVEN_CENTRAL.
+_JGIT_REPO = MAVEN_CENTRAL  # Leave here even if set to MAVEN_CENTRAL.
 
 # set this to use a local version.
 # "/home/<user>/projects/jgit"
@@ -26,28 +26,28 @@
         name = "jgit_lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "1813447ae544b38e36bda9f1599df5f7cff6cfac",
-        src_sha1 = "f0bb1ac954ff529b4e1e01e65ceb0d46d949da9e",
+        sha1 = "4ae44a6157e1bc4c5b373be0c274a8f1d9badd76",
+        src_sha1 = "ad6f30f7b7f016a1390f8d289be7da2a9a1f47c5",
         unsign = True,
     )
     maven_jar(
         name = "jgit_servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "f7a88c3744f864587d50096ab99a58e09e4afd95",
+        sha1 = "687f1d10cfc6424dfbe06acb54b9e67afe2fd917",
         unsign = True,
     )
     maven_jar(
         name = "jgit_archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "af579bcc932fa26f3c4d4ae00e812dc50d50a355",
+        sha1 = "134489ba021e0923735ec85d07f2adde2c8d1e8d",
     )
     maven_jar(
         name = "jgit_junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "b5fc2330bf0418e3a0f773925c57497131f13380",
+        sha1 = "532ad27983c0d77254020ab22d0b2bb8f3d7cd0f",
         unsign = True,
     )
 
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 714fc10..fa9df30 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 714fc1057174227bd5acf4753039fe582c930e18
+Subproject commit fa9df3035c306069758712bfe9ae3425b119bb0c
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
index ed65dae..5cca8b8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
@@ -47,46 +47,60 @@
       .weblinks.show {
         display: block;
       }
+      .loading {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
     </style>
     <style include="gr-menu-page-styles"></style>
     <main class$="[[_computeAdminClass(_isAdmin, _canUpload)]]">
-      <template is="dom-if" if="[[_inheritsFrom]]">
-        <h3 id="inheritsFrom">Rights Inherit From
-          <a href$="[[_computeParentHref(_inheritsFrom.name)]]" rel="noopener">
-              [[_inheritsFrom.name]]</a>
-        </h3>
-      </template>
-      <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
-        History:
-        <template is="dom-repeat" items="[[_weblinks]]" as="link">
-          <a href="[[link.url]]" class="weblink" rel="noopener" target="[[link.target]]">
-            [[link.name]]
-          </a>
-        </template>
+      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+        Loading...
       </div>
-      <gr-button id="editBtn"
-          class$="[[_computeShowEditClass(_sections)]]"
-          on-tap="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
-      <gr-button id="saveBtn"
-          primary
-          class$="[[_computeShowSaveClass(_editing)]]"
-          on-tap="_handleSaveForReview"
-          disabled$="[[!_modified]]">
-            Save for review</gr-button>
-      <template
-          is="dom-repeat"
-          items="{{_sections}}"
-          as="section">
-        <gr-access-section
-            capabilities="[[_capabilities]]"
-            section="{{section}}"
-            labels="[[_labels]]"
-            editing="[[_editing]]"
-            groups="[[_groups]]"></gr-access-section>
-      </template>
-      <gr-button id="addReferenceBtn"
-          class$="[[_computeShowSaveClass(_editing)]]"
-          on-tap="_handleCreateSection">Add Reference</gr-button>
+      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+        <template is="dom-if" if="[[_inheritsFrom]]">
+          <h3 id="inheritsFrom">Rights Inherit From
+            <a href$="[[_computeParentHref(_inheritsFrom.name)]]" rel="noopener">
+                [[_inheritsFrom.name]]</a>
+          </h3>
+        </template>
+        <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
+          History:
+          <template is="dom-repeat" items="[[_weblinks]]" as="link">
+            <a href="[[link.url]]" class="weblink" rel="noopener" target="[[link.target]]">
+              [[link.name]]
+            </a>
+          </template>
+        </div>
+        <gr-button id="editBtn"
+            class="visible"
+            on-tap="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
+        <gr-button id="saveBtn"
+            primary
+            class$="[[_computeShowSaveClass(_editing)]]"
+            on-tap="_handleSaveForReview"
+            disabled$="[[!_modified]]">
+              Save for review</gr-button>
+        <template
+            is="dom-repeat"
+            items="{{_sections}}"
+            as="section">
+          <gr-access-section
+              capabilities="[[_capabilities]]"
+              section="{{section}}"
+              labels="[[_labels]]"
+              editing="[[_editing]]"
+              groups="[[_groups]]"></gr-access-section>
+        </template>
+        <gr-button id="addReferenceBtn"
+            class$="[[_computeShowSaveClass(_editing)]]"
+            on-tap="_handleCreateSection">Add Reference</gr-button>
+      </div>
     </main>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 7149292..d0af0f1 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -100,6 +100,10 @@
       },
       _sections: Array,
       _weblinks: Array,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
     },
 
     behaviors: [
@@ -150,9 +154,14 @@
         this._capabilities = capabilities;
         this._labels = labels;
         this._sections = sections;
+        this._loading = false;
       });
     },
 
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
     _handleEdit() {
       this._editing = !this._editing;
     },
@@ -307,11 +316,6 @@
       });
     },
 
-    _computeShowEditClass(sections) {
-      if (!sections.length) { return ''; }
-      return 'visible';
-    },
-
     _computeShowSaveClass(editing) {
       if (!editing) { return ''; }
       return 'visible';
@@ -326,4 +330,4 @@
           `/admin/repos/${this.encodeURL(repoName, true)},access`;
     },
   });
-})();
\ No newline at end of file
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
index b5efbe3..242662c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
@@ -117,6 +117,7 @@
       });
       repoStub = sandbox.stub(element.$.restAPI, 'getRepo').returns(
           Promise.resolve(repoRes));
+      element._loading = false;
     });
 
     teardown(() => {
@@ -211,6 +212,11 @@
       assert.isTrue(element._computeParentHref.called);
     });
 
+    test('_computeLoadingClass', () => {
+      assert.equal(element._computeLoadingClass(true), 'loading');
+      assert.equal(element._computeLoadingClass(false), '');
+    });
+
     suite('with defined sections', () => {
       const testEditSaveCancelBtns = () => {
         // Edit button is visible and Save button is hidden.
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 46f3e34..419fb56 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -18,6 +18,7 @@
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
+<link rel="import" href="../../../bower_components/paper-tabs/paper-tabs.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
@@ -29,12 +30,14 @@
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
 <link rel="import" href="../../shared/gr-change-status/gr-change-status.html">
+<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
 <link rel="import" href="../gr-change-actions/gr-change-actions.html">
 <link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
 <link rel="import" href="../gr-commit-info/gr-commit-info.html">
@@ -44,6 +47,7 @@
 <link rel="import" href="../gr-messages-list/gr-messages-list.html">
 <link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
 <link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
+<link rel="import" href="../gr-thread-list/gr-thread-list.html">
 
 <dom-module id="gr-change-view">
   <template>
@@ -239,6 +243,24 @@
         display: inline-block;
         margin-right: -5px;
       }
+      paper-tabs {
+        background-color: #fafafa;
+        border-top: 1px solid #ddd;
+        height: 3rem;
+        --paper-tabs-selection-bar-color: var(--color-link);
+      }
+      paper-tab {
+        max-width: 15rem;
+        --paper-tab-ink: var(--color-link);
+      }
+      #threadList,
+      #messageList {
+        display: none;
+      }
+      #threadList.visible,
+      #messageList.visible {
+        display: block;
+      }
       @media screen and (min-width: 80em) {
         .commitMessage {
           max-width: var(--commit-message-max-width, 100ch);
@@ -526,15 +548,35 @@
         <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
         </gr-endpoint-param>
       </gr-endpoint-decorator>
+      <paper-tabs
+          id="commentTabs"
+          on-selected-changed="_handleTabChange">
+        <paper-tab class="changeLog">Change Log</paper-tab>
+        <paper-tab
+            class="commentThreads">
+          <gr-tooltip-content
+              has-tooltip
+              title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]">
+            <span>Comment Threads</span></gr-tooltip-content>
+        </paper-tab>
+      </paper-tabs>
       <gr-messages-list id="messageList"
-          class="hideOnMobileOverlay"
+          class$="hideOnMobileOverlay [[_computeShowMessages(_showMessagesView)]]"
           change-num="[[_changeNum]]"
+          labels="[[_change.labels]]"
           messages="[[_change.messages]]"
           reviewer-updates="[[_change.reviewer_updates]]"
           change-comments="[[_changeComments]]"
           project-name="[[_change.project]]"
           show-reply-buttons="[[_loggedIn]]"
           on-reply="_handleMessageReply"></gr-messages-list>
+      <gr-thread-list
+          id="threadList"
+          threads="[[_commentThreads]]"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          class$="[[_computeShowThreads(_showMessagesView)]]"
+          on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
     </div>
     <gr-overlay id="downloadOverlay" with-backdrop>
       <gr-download-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 028d76a..9c90832 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -96,6 +96,7 @@
         type: Object,
         value() { return document.body; },
       },
+      _commentThreads: Array,
       /** @type {?} */
       _serverConfig: {
         type: Object,
@@ -228,6 +229,10 @@
         type: Boolean,
         value: undefined,
       },
+      _showMessagesView: {
+        type: Boolean,
+        value: true,
+      },
     },
 
     behaviors: [
@@ -245,6 +250,7 @@
       // again upon closing.
       'fullscreen-overlay-opened': '_handleHideBackgroundContent',
       'fullscreen-overlay-closed': '_handleShowBackgroundContent',
+      'diff-comments-modified': '_handleReloadCommentThreads',
     },
     observers: [
       '_labelsChanged(_change.labels.*)',
@@ -329,6 +335,18 @@
       }
     },
 
+    _handleTabChange() {
+      this._showMessagesView = this.$.commentTabs.selected === 0;
+    },
+
+    _computeShowMessages(showSection) {
+      return showSection ? 'visible' : '';
+    },
+
+    _computeShowThreads(showSection) {
+      return !showSection ? 'visible' : '';
+    },
+
     _handleEditCommitMessage(e) {
       this._editingCommitMessage = true;
       this.$.commitMessageEditor.focusTextarea();
@@ -399,6 +417,40 @@
       return false;
     },
 
+    _handleReloadCommentThreads() {
+      // Get any new drafts that have been saved in the diff view and show
+      // in the comment thread view.
+      this._reloadDrafts().then(() => {
+        this._commentThreads = this._changeComments.getAllThreadsForChange()
+            .map(c => Object.assign({}, c));
+        Polymer.dom.flush();
+      });
+    },
+
+    _handleReloadDiffComments(e) {
+      // Keeps the file list counts updated.
+      this._reloadDrafts().then(() => {
+        // Get any new drafts that have been saved in the thread view and show
+        // in the diff view.
+        this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
+            e.detail.path);
+        Polymer.dom.flush();
+      });
+    },
+
+    _computeTotalCommentCounts(unresolvedCount, changeComments) {
+      const draftCount = changeComments.computeDraftCount();
+      const unresolvedString = GrCountStringFormatter.computeString(
+          unresolvedCount, 'unresolved');
+      const draftString = GrCountStringFormatter.computePluralString(
+          draftCount, 'draft');
+
+      return unresolvedString +
+          // Add a comma and space if both unresolved and draft comments exist.
+          (unresolvedString && draftString ? ', ' : '') +
+          draftString;
+    },
+
     _handleCommentSave(e) {
       if (!e.target.comment.__draft) { return; }
 
@@ -580,6 +632,15 @@
       this._changeNum = value.changeNum;
       this.$.relatedChanges.clear();
 
+      // If the comment tabs were already rendered, but set to the wrong initial
+      // value, swap them here so the thread tab doesn't flash before being
+      // swapped out. If the selected tab is undefined, we have to wait until
+      // the page is finished rendering to set selected to 0, otherwise the
+      // animation will not show.
+      if (this.$.commentTabs.selected === 1) {
+        this.$.commentTabs.selected = 0;
+      }
+
       this._reload().then(() => {
         this._performPostLoadTasks();
       });
@@ -600,6 +661,10 @@
 
       this._sendShowChangeEvent();
 
+      // Selected has to be set after the paper-tabs are visible because
+      // the selected underline depends on calculations made by the browser.
+      this.$.commentTabs.selected = 0;
+
       this.async(() => {
         if (this.viewState.scrollTop) {
           document.documentElement.scrollTop =
@@ -1112,6 +1177,8 @@
           .then(comments => {
             this._changeComments = comments;
             this._diffDrafts = Object.assign({}, this._changeComments.drafts);
+            this._commentThreads = this._changeComments.getAllThreadsForChange()
+              .map(c => Object.assign({}, c));
           });
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 2d2bf94..35c4bc1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -58,6 +58,9 @@
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({test: 'config'}); },
         getAccount() { return Promise.resolve(null); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
         _fetchSharedCacheURL() { return Promise.resolve({}); },
       });
       element = fixture('basic');
@@ -313,6 +316,104 @@
       });
     });
 
+    test('diff comments modified', () => {
+      sandbox.spy(element, '_handleReloadCommentThreads');
+      return element._reloadComments().then(() => {
+        element.fire('diff-comments-modified');
+        assert.isTrue(element._handleReloadCommentThreads.called);
+      });
+    });
+
+    test('thread list modified', () => {
+      sandbox.spy(element, '_handleReloadDiffComments');
+      return element._reloadComments().then(() => {
+        element.$.threadList.fire('thread-list-modified');
+        assert.isTrue(element._handleReloadDiffComments.called);
+      });
+    });
+
+    test('thread list modified', () => {
+      return element._reloadComments().then(() => {
+        let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+            .returns(1);
+        assert.equal(element._computeTotalCommentCounts(5,
+            element._changeComments), '5 unresolved, 1 draft');
+        assert.equal(element._computeTotalCommentCounts(0,
+            element._changeComments), '1 draft');
+        draftStub.restore();
+        draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+            .returns(0);
+        assert.equal(element._computeTotalCommentCounts(0,
+            element._changeComments), '');
+        assert.equal(element._computeTotalCommentCounts(1,
+            element._changeComments), '1 unresolved');
+        draftStub.restore();
+        draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+            .returns(2);
+        assert.equal(element._computeTotalCommentCounts(1,
+            element._changeComments), '1 unresolved, 2 drafts');
+        draftStub.restore();
+      });
+    });
+
+    test('thread list and change log tabs', done => {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2, commit: {parents: []}},
+          rev1: {_number: 1, commit: {parents: []}},
+          rev13: {_number: 13, commit: {parents: []}},
+          rev3: {_number: 3, commit: {parents: []}},
+        },
+        current_revision: 'rev3',
+        status: 'NEW',
+        labels: {
+          test: {
+            all: [],
+            default_value: 0,
+            values: [],
+            approved: {},
+          },
+        },
+      };
+      sandbox.stub(element.$.relatedChanges, 'reload');
+      sandbox.stub(element, '_reload').returns(Promise.resolve());
+      sandbox.spy(element, '_paramsChanged');
+      element.params = {view: 'change', changeNum: '1'};
+
+      // When the change is hard reloaded, paramsChanged will not set the tab.
+      // It will be set in postLoadTasks, which requires the flush() to detect.
+      assert.isTrue(element._paramsChanged.called);
+      assert.isUndefined(element.$.commentTabs.selected);
+
+      // Wait for tab to get selected
+      flush(() => {
+        assert.equal(element.$.commentTabs.selected, 0);
+        assert.notEqual(getComputedStyle(element.$.messageList).display,
+            'none');
+        assert.equal(getComputedStyle(element.$.threadList).display, 'none');
+
+        // Switch to comment thread tab
+        MockInteractions.tap(element.$$('paper-tab.commentThreads'));
+        assert.equal(element.$.commentTabs.selected, 1);
+        assert.equal(getComputedStyle(element.$.messageList).display,
+            'none');
+        assert.notEqual(getComputedStyle(element.$.threadList).display,
+            'none');
+
+        // When the change is partially reloaded (ex: Shift+R), paramsChanged
+        // will set the tab, if it was previously set to the thread tab.
+        element._paramsChanged(element.params);
+        assert.equal(element.$.commentTabs.selected, 0);
+        done();
+      });
+    });
+
     test('reply button is not visible when logged out', () => {
       assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
       element._loggedIn = true;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 4f086ce..640c273 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -962,15 +962,6 @@
       const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
       if (!threadEl) { return; }
 
-      // In gr-diff-comment, _toggleResolved update the comment when
-      // it was previously set and the comment is not being edited.
-      // When comments was not reset/flushed prior to updating, the
-      // comment would attempt to be saved again. If this becomes a
-      // performance issue, will need to evaluate other options, but for
-      // now, the reset and flush is necessary.
-      threadEl.comments = [];
-      Polymer.dom.flush();
-
       const newComments = this.changeComments.getCommentsForThread(rootId);
 
       // If newComments is null, it means that a single draft was
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 973219e..0898a60 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -21,11 +21,13 @@
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../styles/gr-voting-styles.html">
 
 <link rel="import" href="../gr-comment-list/gr-comment-list.html">
 
 <dom-module id="gr-message">
   <template>
+    <style include="gr-voting-styles"></style>
     <style include="shared-styles">
       :host {
         border-bottom: 1px solid #ddd;
@@ -131,11 +133,25 @@
       .replyContainer {
         padding: .5em 0 0 0;
       }
-      .positiveVote {
-        box-shadow: inset 0 3.8em #d4ffd4;
+      .score {
+        border: 1px solid rgba(0,0,0,.12);
+        border-radius: 3px;
+        color: #000;
+        display: inline-block;
+        margin: -.1em 0;
+        padding: 0 .1em;
       }
-      .negativeVote {
-        box-shadow: inset 0 3.8em #ffd4d4;
+      .score.negative {
+        background-color: var(--vote-color-negative);
+      }
+      .score.negative.min {
+        background-color: var(--vote-color-min);
+      }
+      .score.positive {
+        background-color: var(--vote-color-positive);
+      }
+      .score.positive.max {
+        background-color: var(--vote-color-max);
       }
       gr-account-label {
         --gr-account-label-text-style: {
@@ -154,6 +170,11 @@
           <gr-account-label
               account="[[author]]"
               hide-avatar></gr-account-label>
+          <template is="dom-repeat" items="[[_getScores(message)]]" as="score">
+            <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
+              [[score.label]] [[score.value]]
+            </span>
+          </template>
         </div>
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 0e73c6e..fccde5b 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -14,7 +14,6 @@
 (function() {
   'use strict';
 
-  const CI_LABELS = ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'];
   const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+: /;
   const LABEL_TITLE_SCORE_PATTERN = /^([A-Za-z0-9-]+)([+-]\d+)$/;
 
@@ -79,6 +78,13 @@
         type: String,
         observer: '_projectNameChanged',
       },
+
+      /**
+       * A mapping from label names to objects representing the minimum and
+       * maximum possible values for that label.
+       */
+      labelExtremes: Object,
+
       /**
        * @type {{ commentlinks: Array }}
        */
@@ -175,41 +181,42 @@
       return event.type === 'REVIEWER_UPDATE';
     },
 
-    _isMessagePositive(message) {
-      if (!message.message) { return null; }
+    _getScores(message) {
+      if (!message.message) { return []; }
       const line = message.message.split('\n', 1)[0];
       const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-      if (!line.match(patchSetPrefix)) { return null; }
+      if (!line.match(patchSetPrefix)) { return []; }
       const scoresRaw = line.split(patchSetPrefix)[1];
-      if (!scoresRaw) { return null; }
-      const scores = scoresRaw.split(' ');
-      if (!scores.length) { return null; }
-      const {min, max} = scores
+      if (!scoresRaw) { return []; }
+      return scoresRaw.split(' ')
           .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
           .filter(ms => ms && ms.length === 3)
-          .filter(([, label]) => !CI_LABELS.includes(label))
-          .map(([, , score]) => score)
-          .map(s => parseInt(s, 10))
-          .reduce(({min, max}, s) =>
-              ({min: (s < min ? s : min), max: (s > max ? s : max)}),
-              {min: 0, max: 0});
-      if (max - min === 0) {
-        return 0;
-      } else {
-        return (max + min) > 0 ? 1 : -1;
+          .map(ms => ({label: ms[1], value: ms[2]}));
+    },
+
+    _computeScoreClass(score, labelExtremes) {
+      const classes = [];
+      if (score.value > 0) {
+        classes.push('positive');
+      } else if (score.value < 0) {
+        classes.push('negative');
       }
+      const extremes = labelExtremes[score.label];
+      if (extremes) {
+        const intScore = parseInt(score.value, 10);
+        if (intScore === extremes.max) {
+          classes.push('max');
+        } else if (intScore === extremes.min) {
+          classes.push('min');
+        }
+      }
+      return classes.join(' ');
     },
 
     _computeClass(expanded, showAvatar, message) {
       const classes = [];
       classes.push(expanded ? 'expanded' : 'collapsed');
       classes.push(showAvatar ? 'showAvatar' : 'hideAvatar');
-      const scoreQuality = this._isMessagePositive(message);
-      if (scoreQuality === 1) {
-        classes.push('positiveVote');
-      } else if (scoreQuality === -1) {
-        classes.push('negativeVote');
-      }
       return classes.join(' ');
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 658dcad..b0ba2d9 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -163,22 +163,29 @@
       });
     });
 
-    test('negative vote', () => {
+    test('votes', () => {
       element.message = {
         author: {},
         expanded: false,
         message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Ready+1',
       };
-      assert.isOk(Polymer.dom(element.root).querySelector('.negativeVote'));
-    });
-
-    test('positive vote', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified-1 Code-Review+2 Trybot-Ready-1',
+      element.labelExtremes = {
+        'Verified': {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Trybot-Ready': {max: 3, min: 0},
       };
-      assert.isOk(Polymer.dom(element.root).querySelector('.positiveVote'));
+      flushAsynchronousOperations();
+      const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
+
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      assert.isFalse(scoreChips[2].classList.contains('min'));
     });
 
     test('false negative vote', () => {
@@ -187,7 +194,9 @@
         expanded: false,
         message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
       };
-      assert.isNotOk(Polymer.dom(element.root).querySelector('.negativeVote'));
+      element.labelExtremes = {};
+      const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 0);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index df9ce54..2a173e5 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -26,7 +26,8 @@
     <style include="shared-styles">
       :host,
       .messageListControls {
-        display: block;
+        display: flex;
+        justify-content: space-between;
       }
       .header {
         align-items: center;
@@ -64,8 +65,6 @@
       }
     </style>
     <div class="header">
-      <h3>Messages</h3>
-      <div class="messageListControls container">
         <span
             id="automatedMessageToggleContainer"
             class="container"
@@ -82,7 +81,6 @@
           [[_computeExpandCollapseMessage(_expanded)]]
         </gr-button>
       </div>
-    </div>
     <span
         id="messageControlsContainer"
         hidden$="[[_computeShowHideTextHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
@@ -111,6 +109,7 @@
           project-name="[[projectName]]"
           show-reply-button="[[showReplyButtons]]"
           on-scroll-to="_handleScrollTo"
+          label-extremes="[[_labelExtremes]]"
           data-message-id$="[[message.id]]"></gr-message>
     </template>
     <gr-reporting id="reporting" category="message-list"></gr-reporting>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 9ccadf7..00097b0 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -41,6 +41,7 @@
         type: Boolean,
         value: false,
       },
+      labels: Object,
 
       _expanded: {
         type: Boolean,
@@ -66,6 +67,11 @@
         type: Array,
         value() { return []; },
       },
+
+      _labelExtremes: {
+        type: Object,
+        computed: '_computeLabelExtremes(labels.*)',
+      },
     },
 
     scrollToMessage(messageID) {
@@ -331,5 +337,24 @@
           this._numRemaining(visibleMessages, messages, hideAutomated);
       return total <= this._getDelta(visibleMessages, messages, hideAutomated);
     },
+
+    /**
+     * Compute a mapping from label name to objects representing the minimum and
+     * maximum possible values for that label.
+     */
+    _computeLabelExtremes(labelRecord) {
+      const extremes = {};
+      const labels = labelRecord.base;
+      if (!labels) { return extremes; }
+      for (const key of Object.keys(labels)) {
+        if (!labels[key] || !labels[key].values) { continue; }
+        const values = Object.keys(labels[key].values)
+            .map(v => parseInt(v, 10));
+        values.sort();
+        if (!values.length) { continue; }
+        extremes[key] = {min: values[0], max: values[values.length - 1]};
+      }
+      return extremes;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 453d97a..06801b8 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -540,5 +540,40 @@
       assert.equal(messages.length, 5);
       assert.isFalse(element._hasAutomatedMessages(messages));
     });
+
+    test('_computeLabelExtremes', () => {
+      const computeSpy = sandbox.spy(element, '_computeLabelExtremes');
+
+      element.labels = null;
+      assert.isTrue(computeSpy.calledOnce);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {};
+      assert.isTrue(computeSpy.calledTwice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {}};
+      assert.isTrue(computeSpy.calledThrice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {values: {}}};
+      assert.equal(computeSpy.callCount, 4);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {values: {'-12': {}}}};
+      assert.equal(computeSpy.callCount, 5);
+      assert.deepEqual(computeSpy.lastCall.returnValue,
+          {'my-label': {min: -12, max: -12}});
+
+      element.labels = {
+        'my-label': {values: {'-12': {}}},
+        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
+      };
+      assert.equal(computeSpy.callCount, 6);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {
+        'my-label': {min: -12, max: -12},
+        'other-label': {min: -1, max: 1},
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
index 2572c20..c1f6820 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
@@ -62,7 +62,7 @@
         <paper-toggle-button
             id="unresolvedToggle"
             on-change="_toggleUnresolved"></paper-toggle-button>
-          Only Unresolved threads</div>
+          Only unresolved threads</div>
       <div class="toggleItem">
         <paper-toggle-button
             id="draftToggle"
@@ -73,7 +73,7 @@
       <template is="dom-if" if="[[!threads.length]]">
         There are no inline comment threads on any diff for this change.
       </template>
-      <template is="dom-repeat" items="[[threads]]" as="thread">
+      <template is="dom-repeat" items="[[_sortedThreads]]" as="thread">
         <gr-diff-comment-thread
             show-file-path
             change-num="[[changeNum]]"
@@ -84,7 +84,9 @@
             line-num="[[thread.line]]"
             patch-num="[[thread.patchNum]]"
             path="[[thread.path]]"
-            root-id="[[thread.rootId]]"></gr-diff-comment-thread>
+            root-id="{{thread.rootId}}"
+            on-thread-changed="_handleCommentsChanged"
+            on-thread-discard="_handleThreadDiscard"></gr-diff-comment-thread>
       </template>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index 73d7e31..ba7bc5c 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -14,19 +14,88 @@
 (function() {
   'use strict';
 
+  /**
+   * Fired when a comment is saved or deleted
+   *
+   * @event thread-list-modified
+   */
+
   Polymer({
     is: 'gr-thread-list',
 
     properties: {
+      /** @type {?} */
       change: Object,
       threads: Array,
       changeNum: String,
-      ignoreChanges: {
-        type: Boolean,
-        value: false,
+      _sortedThreads: {
+        type: Array,
+        computed: '_computeSortedThreads(threads.*)',
       },
     },
 
+    /**
+     * Order as follows:
+     *  - Unresolved threads with drafts (reverse chronological)
+     *  - Unresolved threads without drafts (reverse chronological)
+     *  - Resolved threads with drafts (reverse chronological)
+     *  - Resolved threads without drafts (reverse chronological)
+     * @param {!Object} changeRecord
+     * @return {!Array}
+     */
+    _computeSortedThreads(changeRecord) {
+      const threads = changeRecord.base;
+      if (!threads) { return []; }
+      return threads.map(this._getThreadWithSortInfo).sort((c1, c2) => {
+        const c1Date = c1.__date || util.parseDate(c1.updated);
+        const c2Date = c2.__date || util.parseDate(c2.updated);
+        const dateCompare = c2Date - c1Date;
+        if (c2.unresolved || c1.unresolved) {
+          if (!c1.unresolved) { return 1; }
+          if (!c2.unresolved) { return -1; }
+        }
+        if (c2.hasDraft || c1.hasDraft) {
+          if (!c1.hasDraft) { return 1; }
+          if (!c2.hasDraft) { return -1; }
+        }
+
+        if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
+          return 0;
+        }
+        return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+      }).map(threadInfo => threadInfo.thread);
+    },
+
+    _getThreadWithSortInfo(thread) {
+      const lastComment = thread.comments[thread.comments.length - 1] || {};
+      return {
+        thread,
+        unresolved: !!lastComment.unresolved,
+        hasDraft: !!lastComment.__draft,
+        updated: lastComment.updated,
+      };
+    },
+
+    removeThread(rootId) {
+      for (let i = 0; i < this.threads.length; i++) {
+        if (this.threads[i].rootId === rootId) {
+          this.splice('threads', i, 1);
+          // Needed to ensure threads get re-rendered in the correct order.
+          Polymer.dom.flush();
+          return;
+        }
+      }
+    },
+
+    _handleThreadDiscard(e) {
+      this.removeThread(e.detail.rootId);
+    },
+
+    _handleCommentsChanged(e) {
+      this.dispatchEvent(new CustomEvent('thread-list-modified',
+          {detail: {rootId: e.detail.rootId, path: e.detail.path}}));
+    },
+
     _isOnParent(side) {
       return !!side;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
index 2993e98..c0be7d7 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
@@ -35,7 +35,7 @@
   suite('gr-thread-list tests', () => {
     let element;
     let sandbox;
-    let threadsElements;
+    let threadElements;
     const computeVisibleNumber = threads => {
       let count = 0;
       for (const thread of threads) {
@@ -132,9 +132,53 @@
           rootId: '8caddf38_44770ec1',
           start_datetime: '2018-02-13 22:48:40.000000000',
         },
+        {
+          comments: [
+            {
+              __path: '/COMMIT_MSG',
+              author: {
+                _account_id: 1000000,
+                name: 'user',
+                username: 'user',
+              },
+              patch_set: 2,
+              id: 'scaddf38_44770ec1',
+              line: 4,
+              updated: '2018-02-14 22:48:40.000000000',
+              message: 'Yet another unresolved comment',
+              unresolved: true,
+            },
+          ],
+          patchNum: 2,
+          path: '/COMMIT_MSG',
+          line: 4,
+          rootId: 'scaddf38_44770ec1',
+          start_datetime: '2018-02-14 22:48:40.000000000',
+        },
+        {
+          comments: [
+            {
+              id: 'zcf0b9fa_fe1a5f62',
+              path: '/COMMIT_MSG',
+              line: 6,
+              updated: '2018-02-15 22:48:48.018000000',
+              message: 'resolved draft',
+              unresolved: false,
+              __draft: true,
+              __draftID: '0.m683trwff68',
+              __editing: false,
+              patch_set: '2',
+            },
+          ],
+          patchNum: 4,
+          path: '/COMMIT_MSG',
+          line: 6,
+          rootId: 'zcf0b9fa_fe1a5f62',
+          start_datetime: '2018-02-09 18:49:18.000000000',
+        },
       ];
       flushAsynchronousOperations();
-      threadsElements = Polymer.dom(element.root)
+      threadElements = Polymer.dom(element.root)
           .querySelectorAll('gr-diff-comment-thread');
     });
 
@@ -142,20 +186,47 @@
       sandbox.restore();
     });
 
-    test('there are three threads by default', () => {
-      assert.equal(computeVisibleNumber(threadsElements), 3);
+    test('there are five threads by default', () => {
+      assert.equal(computeVisibleNumber(threadElements), 5);
+    });
+
+    test('_computeSortedThreads', () => {
+      assert.equal(element._sortedThreads.length, 5);
+      // Draft and unresolved
+      assert.equal(element._sortedThreads[0].rootId, 'ecf0b9fa_fe1a5f62');
+      // unresolved
+      assert.equal(element._sortedThreads[1].rootId, 'scaddf38_44770ec1');
+      // unresolved
+      assert.equal(element._sortedThreads[2].rootId, '8caddf38_44770ec1');
+      // resolved and draft
+      assert.equal(element._sortedThreads[3].rootId, 'zcf0b9fa_fe1a5f62');
+      // resolved
+      assert.equal(element._sortedThreads[4].rootId, '09a9fb0a_1484e6cf');
+    });
+
+    test('thread removal', () => {
+      threadElements[1].fire('thread-discard', {rootId: 'scaddf38_44770ec1'});
+      flushAsynchronousOperations();
+      assert.equal(element._sortedThreads.length, 4);
+      assert.equal(element._sortedThreads[0].rootId, 'ecf0b9fa_fe1a5f62');
+      // unresolved
+      assert.equal(element._sortedThreads[1].rootId, '8caddf38_44770ec1');
+      // resolved and draft
+      assert.equal(element._sortedThreads[2].rootId, 'zcf0b9fa_fe1a5f62');
+      // resolved
+      assert.equal(element._sortedThreads[3].rootId, '09a9fb0a_1484e6cf');
     });
 
     test('toggle unresolved only shows unressolved comments', () => {
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
-      assert.equal(computeVisibleNumber(threadsElements), 2);
+      assert.equal(computeVisibleNumber(threadElements), 3);
     });
 
     test('toggle drafts only shows threads with draft comments', () => {
       MockInteractions.tap(element.$.draftToggle);
       flushAsynchronousOperations();
-      assert.equal(computeVisibleNumber(threadsElements), 1);
+      assert.equal(computeVisibleNumber(threadElements), 2);
     });
 
     test('toggle drafts and unresolved only shows threads with drafts and ' +
@@ -163,7 +234,20 @@
       MockInteractions.tap(element.$.draftToggle);
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
-      assert.equal(computeVisibleNumber(threadsElements), 1);
+      assert.equal(computeVisibleNumber(threadElements), 1);
+    });
+
+    test('modification events are consumed and displatched', () => {
+      sandbox.spy(element, '_handleCommentsChanged');
+      const dispatchSpy = sandbox.stub();
+      element.addEventListener('thread-list-modified', dispatchSpy);
+      threadElements[0].fire('thread-changed', {
+        rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'});
+      assert.isTrue(element._handleCommentsChanged.called);
+      assert.isTrue(dispatchSpy.called);
+      assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
+          'ecf0b9fa_fe1a5f62');
+      assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
     });
   });
-</script>
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
index 67b4f51..cf66e57 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -178,7 +178,7 @@
     for (const path of Object.keys(paths)) {
       let commentsToAdd = this.getAllCommentsForPath(path, opt_patchNum);
       if (opt_includeDrafts) {
-        const drafts = this.getAllDraftsForPath(path)
+        const drafts = this.getAllDraftsForPath(path, opt_patchNum)
             .map(d => Object.assign({__draft: true}, d));
         commentsToAdd = commentsToAdd.concat(drafts);
       }
@@ -207,6 +207,7 @@
    *
    * @param {!string} path
    * @param {number=} opt_patchNum
+   * @param {boolean=} opt_includeDrafts
    * @return {!Array}
    */
   ChangeComments.prototype.getAllCommentsForPath = function(path,
@@ -323,18 +324,20 @@
   };
 
   /**
-   * Computes a string counting the number of commens in a given file and path.
+   * Computes a string counting the number of draft comments in the entire
+   * change, optionally filtered by path and/or patchNum.
    *
-   * @param {number} patchNum
+   * @param {number=} opt_patchNum
    * @param {string=} opt_path
    * @return {number}
    */
-  ChangeComments.prototype.computeDraftCount = function(patchNum, opt_path) {
+  ChangeComments.prototype.computeDraftCount = function(opt_patchNum,
+      opt_path) {
     if (opt_path) {
-      return this.getAllDraftsForPath(opt_path, patchNum).length;
+      return this.getAllDraftsForPath(opt_path, opt_patchNum).length;
     }
-    const allComments = this.getAllDrafts(patchNum);
-    return this._commentObjToArray(allComments).length;
+    const allDrafts = this.getAllDrafts(opt_patchNum);
+    return this._commentObjToArray(allDrafts).length;
   };
 
   /**
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
index 4cf79fa..2b06a10 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -248,6 +248,14 @@
                 updated: makeTime(3),
               },
             ],
+            'file/two': [
+              {
+                id: 5,
+                patch_set: 3,
+                line: 1,
+                updated: makeTime(3),
+              },
+            ],
           };
           element._changeComments._robotComments = {
             'file/one': [
@@ -340,13 +348,13 @@
           comments = element._changeComments.getCommentsBySideForPath(path,
               patchRange);
           assert.equal(comments.left.length, 0);
-          assert.equal(comments.right.length, 1);
+          assert.equal(comments.right.length, 2);
 
           patchRange.basePatchNum = 2;
           comments = element._changeComments.getCommentsBySideForPath(path,
               patchRange);
           assert.equal(comments.left.length, 1);
-          assert.equal(comments.right.length, 1);
+          assert.equal(comments.right.length, 2);
 
           patchRange.basePatchNum = PARENT;
           path = 'file/three';
@@ -429,6 +437,8 @@
               .computeDraftCount(1, 'file/one'), 0);
           assert.equal(element._changeComments
               .computeDraftCount(2, 'file/three'), 0);
+          assert.equal(element._changeComments
+              .computeDraftCount(), 3);
         });
 
         test('getAllPublishedComments', () => {
@@ -456,7 +466,7 @@
           comments = element._changeComments.getAllComments(true);
           assert.equal(Object.keys(comments).length, 4);
           assert.equal(Object.keys(comments[['file/one']]).length, 6);
-          assert.equal(Object.keys(comments[['file/two']]).length, 2);
+          assert.equal(Object.keys(comments[['file/two']]).length, 3);
           comments = element._changeComments.getAllComments(true, 2);
           assert.equal(Object.keys(comments).length, 4);
           assert.equal(Object.keys(comments[['file/one']]).length, 6);
@@ -627,6 +637,21 @@
             }, {
               comments: [
                 {
+                  id: 5,
+                  patch_set: 3,
+                  line: 1,
+                  __path: 'file/two',
+                  __draft: true,
+                  updated: '2013-02-26 15:03:43.986000000',
+                },
+              ],
+              rootId: 5,
+              patchNum: 3,
+              path: 'file/two',
+              line: 1,
+            }, {
+              comments: [
+                {
                   id: 11,
                   patch_set: 2,
                   side: 'PARENT',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index cb2eebe..34e006f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -83,7 +83,8 @@
             root-id="[[rootId]]"
             project-config="[[_projectConfig]]"
             on-create-fix-comment="_handleCommentFix"
-            on-comment-discard="_handleCommentDiscard"></gr-diff-comment>
+            on-comment-discard="_handleCommentDiscard"
+            on-comment-save="_handleCommentSavedOrDiscarded"></gr-diff-comment>
       </template>
       <div id="commentInfoContainer"
           hidden$="[[_hideActions(_showActions, _lastComment)]]">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index d0a1f9b..8f5821f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -26,6 +26,12 @@
      * @event thread-discard
      */
 
+    /**
+     * Fired when a comment in the thread is permanently modified.
+     *
+     * @event thread-changed
+     */
+
     properties: {
       changeNum: String,
       comments: {
@@ -367,7 +373,9 @@
     },
 
     _computeRootId(comments) {
-      if (!comments.base.length) { return null; }
+      // Keep the root ID even if the comment was removed, so that notification
+      // to sync will know which thread to remove.
+      if (!comments.base.length) { return this.rootId; }
       const rootComment = comments.base[0];
       return rootComment.id || rootComment.__draftID;
     },
@@ -384,6 +392,7 @@
       if (this.comments.length === 0) {
         this.fireRemoveSelf();
       }
+      this._handleCommentSavedOrDiscarded(e);
 
       // Check to see if there are any other open comments getting edited and
       // set the local storage value to its message value.
@@ -401,6 +410,12 @@
       }
     },
 
+    _handleCommentSavedOrDiscarded(e) {
+      this.dispatchEvent(new CustomEvent('thread-changed',
+          {detail: {rootId: this.rootId, path: this.path},
+            bubbles: false}));
+    },
+
     _handleCommentUpdate(e) {
       const comment = e.detail.comment;
       const index = this._indexOf(comment, this.comments);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index de6c3b01..5eb8b94 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -203,8 +203,10 @@
 
   suite('comment action tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(false); },
         saveDiffDraft() {
@@ -235,10 +237,15 @@
         line: 5,
         message: 'is this a crossover episode!?',
         updated: '2015-12-08 19:48:33.843000000',
+        path: '/path/to/file.txt',
       }];
       flushAsynchronousOperations();
     });
 
+    teardown(() => {
+      sandbox.restore();
+    });
+
     test('reply', done => {
       const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
@@ -344,6 +351,28 @@
       });
     });
 
+    test('save', done => {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.path = '/path/to/file.txt';
+      const commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+
+      const saveOrDiscardStub = sandbox.stub();
+      element.addEventListener('thread-changed', saveOrDiscardStub);
+      element.$$('gr-diff-comment')._fireSave();
+
+      flush(() => {
+        assert.isTrue(saveOrDiscardStub.called);
+        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+            'baf0414d_60047215');
+        assert.equal(element.rootId, 'baf0414d_60047215');
+        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+            '/path/to/file.txt');
+        done();
+      });
+    });
+
     test('please fix', done => {
       element.changeNum = '42';
       element.patchNum = '1';
@@ -367,6 +396,7 @@
     test('discard', done => {
       element.changeNum = '42';
       element.patchNum = '1';
+      element.path = '/path/to/file.txt';
       element.push('comments', element._newReply(
           element.comments[0].id,
           element.comments[0].line,
@@ -374,6 +404,8 @@
           'it’s pronouced jiff, not giff'));
       flushAsynchronousOperations();
 
+      const saveOrDiscardStub = sandbox.stub();
+      element.addEventListener('thread-changed', saveOrDiscardStub);
       const draftEl =
           Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
       assert.ok(draftEl);
@@ -382,11 +414,46 @@
           return c.__draft == true;
         });
         assert.equal(drafts.length, 0);
+        assert.isTrue(saveOrDiscardStub.called);
+        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+            element.rootId);
+        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+            element.path);
         done();
       });
-      draftEl.fire('comment-discard', null, {bubbles: false});
+      draftEl.fire('comment-discard', {comment: draftEl.comment},
+          {bubbles: false});
     });
 
+    test('discard with a single comment still fires event with previous rootId',
+        done => {
+          element.changeNum = '42';
+          element.patchNum = '1';
+          element.path = '/path/to/file.txt';
+          element.comments = [];
+          element.addOrEditDraft('1');
+          flushAsynchronousOperations();
+          const rootId = element.rootId;
+          assert.isOk(rootId);
+
+          const saveOrDiscardStub = sandbox.stub();
+          element.addEventListener('thread-changed', saveOrDiscardStub);
+          const draftEl =
+          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[0];
+          assert.ok(draftEl);
+          draftEl.addEventListener('comment-discard', () => {
+            assert.equal(element.comments.length, 0);
+            assert.isTrue(saveOrDiscardStub.called);
+            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+                rootId);
+            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+                element.path);
+            done();
+          });
+          draftEl.fire('comment-discard', {comment: draftEl.comment},
+          {bubbles: false});
+        });
+
     test('first editing comment does not add __otherEditing attribute', () => {
       element.comments = [{
         author: {
@@ -461,7 +528,8 @@
         storageStub.restore();
         done();
       });
-      draftEl.fire('comment-discard', null, {bubbles: false});
+      draftEl.fire('comment-discard', {comment: draftEl.comment},
+          {bubbles: false});
     });
 
     test('comment-update', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index 514bc80..e1ebbf2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -290,7 +290,8 @@
           <div class="action resolve hideOnPublished">
             <label>
               <input type="checkbox"
-                  checked$="[[resolved]]"
+                  id="resolvedCheckbox"
+                  checked="[[resolved]]"
                   on-change="_handleToggleResolved">
               Resolved
             </label>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index e45702c..c48f7ea 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -113,10 +113,7 @@
       },
       commentSide: String,
 
-      resolved: {
-        type: Boolean,
-        observer: '_toggleResolved',
-      },
+      resolved: Boolean,
 
       _numPendingDraftRequests: {
         type: Object,
@@ -581,17 +578,10 @@
 
     _handleToggleResolved() {
       this.resolved = !this.resolved;
-    },
-
-    _toggleResolved(resolved, previousValue) {
-      // Do not proceed if this call is for the initial definition of the
-      // resolved property.
-      if (previousValue === undefined) { return; }
-
       // Modify payload instead of this.comment, as this.comment is passed from
       // the parent by ref.
       const payload = this._getEventPayload();
-      payload.comment.unresolved = !resolved;
+      payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
       this.fire('comment-update', payload);
       if (!this.editing) {
         // Save the resolved state immediately.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index c09e289..8c78946 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -636,7 +636,9 @@
 
     test('draft prevent save when disabled', () => {
       const saveStub = sandbox.stub(element, 'save');
+      element.showActions = true;
       element.draft = true;
+      MockInteractions.tap(element.$.header);
       MockInteractions.tap(element.$$('.edit'));
       element._messageText = 'good news, everyone!';
       element.flushDebouncer('fire-update');
@@ -680,7 +682,7 @@
       assert.isFalse(element.$$('.resolve input').checked);
     });
 
-    test('resolved checkbox saves when !editing', () => {
+    test('resolved checkbox saves with tap when !editing', () => {
       element.editing = false;
       const save = sandbox.stub(element, 'save');
 
@@ -688,6 +690,9 @@
       assert.isTrue(element.$$('.resolve input').checked);
       element.comment = {unresolved: true};
       assert.isFalse(element.$$('.resolve input').checked);
+      assert.isFalse(save.called);
+      MockInteractions.tap(element.$.resolvedCheckbox);
+      assert.isTrue(element.$$('.resolve input').checked);
       assert.isTrue(save.called);
     });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 5b85c20..b1b0670 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -540,6 +540,12 @@
       return {path: fileList[idx]};
     },
 
+    _getReviewedStatus(editMode, changeNum, patchNum, path) {
+      if (editMode) { return Promise.resolve(false); }
+      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
+          .then(files => files.includes(path));
+    },
+
     _paramsChanged(value) {
       if (value.view !== Gerrit.Nav.View.DIFF) { return; }
 
@@ -631,7 +637,17 @@
 
     _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
       const params = paramsRecord.base || {};
-      if (!_loggedIn || _prefs.manual_review) { return; }
+      if (!_loggedIn) { return; }
+
+      if (_prefs.manual_review) {
+        // Checkbox state needs to be set explicitly only when manual_review
+        // is specified.
+        this._getReviewedStatus(this.editMode, this._changeNum,
+            this._patchRange.patchNum, this._path).then(status => {
+              this.$.reviewed.checked = status;
+            });
+        return;
+      }
 
       if (params.view === Gerrit.Nav.View.DIFF) {
         this._setReviewed(true);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 7c94a00..0e9c34b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -60,6 +60,7 @@
         getDiffComments() { return Promise.resolve({}); },
         getDiffRobotComments() { return Promise.resolve({}); },
         getDiffDrafts() { return Promise.resolve({}); },
+        getReviewedFiles() { return Promise.resolve([]); },
       });
       element = fixture('basic');
       return element._loadComments();
@@ -521,8 +522,10 @@
     test('_prefs.manual_review is respected', () => {
       const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
           () => Promise.resolve());
-      sandbox.stub(element.$.diff, 'reload');
+      const getReviewedStub = sandbox.stub(element, '_getReviewedStatus',
+          () => Promise.resolve());
 
+      sandbox.stub(element.$.diff, 'reload');
       element._loggedIn = true;
       element.params = {
         view: Gerrit.Nav.View.DIFF,
@@ -535,10 +538,13 @@
       flushAsynchronousOperations();
 
       assert.isFalse(saveReviewedStub.called);
+      assert.isTrue(getReviewedStub.called);
+
       element._prefs = {};
       flushAsynchronousOperations();
 
       assert.isTrue(saveReviewedStub.called);
+      assert.isTrue(getReviewedStub.calledOnce);
     });
 
     test('file review status', () => {
@@ -994,6 +1000,25 @@
           [{value: '/foo'}, {value: '/bar'}]), 'show');
     });
 
+    test('_getReviewedStatus', () => {
+      const promises = [];
+      element.$.restAPI.getReviewedFiles.restore();
+
+      sandbox.stub(element.$.restAPI, 'getReviewedFiles')
+          .returns(Promise.resolve(['path']));
+
+      promises.push(element._getReviewedStatus(true, null, null, 'path')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, null, null, 'path')
+          .then(reviewed => assert.isTrue(reviewed)));
+
+      return Promise.all(promises);
+    });
+
     suite('editMode behavior', () => {
       setup(() => {
         element._loggedIn = true;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 65544d4..079600c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -158,8 +158,9 @@
         content: '\A';
       }
       .contextControl {
-        background-color: #fef;
-        color: #849;
+        background-color: #fff7d4;
+        border: 1px solid #f6e6a5;
+        color: rgba(0,0,0.54);
       }
       .contextControl gr-button {
         display: inline-block;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index c148a10..19b3c6d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -48,6 +48,12 @@
      * @event show-auth-required
      */
 
+    /**
+     * Fired when a comment is saved or discarded
+     *
+     * @event diff-comments-modified
+     */
+
     properties: {
       changeNum: String,
       noAutoRender: {
@@ -260,6 +266,11 @@
       this.classList.remove('showBlame');
     },
 
+    _handleCommentSaveOrDiscard() {
+      this.dispatchEvent(new CustomEvent('diff-comments-modified',
+          {bubbles: true}));
+    },
+
     /** @return {boolean}} */
     _canRender() {
       return !!this.changeNum && !!this.patchRange && !!this.path &&
@@ -511,6 +522,7 @@
     _handleCommentDiscard(e) {
       const comment = e.detail.comment;
       this._removeComment(comment);
+      this._handleCommentSaveOrDiscard();
     },
 
     _removeComment(comment) {
@@ -523,6 +535,7 @@
       const side = e.detail.comment.__commentSide;
       const idx = this._findDraftIndex(comment, side);
       this.set(['comments', side, idx], comment);
+      this._handleCommentSaveOrDiscard();
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 1f06dd5..7526141 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -984,6 +984,28 @@
           assert.include(element.comments.left, comment);
         });
 
+        test('discarding a draft', () => {
+          const draftID = 'tempID';
+          const id = 'savedID';
+          const comment = {
+            __draft: true,
+            __draftID: draftID,
+            side: 'PARENT',
+            __commentSide: 'left',
+          };
+          const diffCommentsModifiedStub = sandbox.stub();
+          element.addEventListener('diff-comments-modified',
+              diffCommentsModifiedStub);
+          element.comments.left.push(comment);
+          comment.id = id;
+          element.fire('comment-discard', {comment});
+          const drafts = element.comments.left.filter(item => {
+            return item.__draftID === draftID;
+          });
+          assert.equal(drafts.length, 0);
+          assert.isTrue(diffCommentsModifiedStub.called);
+        });
+
         test('saving a draft', () => {
           const draftID = 'tempID';
           const id = 'savedID';
@@ -993,14 +1015,18 @@
             side: 'PARENT',
             __commentSide: 'left',
           };
+          const diffCommentsModifiedStub = sandbox.stub();
+          element.addEventListener('diff-comments-modified',
+              diffCommentsModifiedStub);
           element.comments.left.push(comment);
           comment.id = id;
-          element.fire('comment-update', {comment});
+          element.fire('comment-save', {comment});
           const drafts = element.comments.left.filter(item => {
             return item.__draftID === draftID;
           });
           assert.equal(drafts.length, 1);
           assert.equal(drafts[0].id, id);
+          assert.isTrue(diffCommentsModifiedStub.called);
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 2f2aa51..3879495 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -79,13 +79,14 @@
         border-bottom: 1px solid pink;
       }
       gr-main-header {
+        background-color: var(--header-background-color);
+        border-bottom: 1px solid #ddd;
         /* The combination of left, width, position allow this to be
           floating. */
         left: 0;
-        width: 100vw;
-        background-color: var(--header-background-color);
         padding: 0 var(--default-horizontal-margin);
         position: sticky;
+        width: 100vw;
         /* Needed for the menu dropdowns to display on top of the sticky
           headers with z-index of 1.*/
         z-index: 2;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
index bcae0bf..017f49f 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -15,9 +15,10 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-group-list">
   <template>
@@ -47,7 +48,11 @@
         <tbody>
           <template is="dom-repeat" items="[[_groups]]">
             <tr>
-              <td class="nameColumn">[[item.name]]</td>
+              <td class="nameColumn">
+                <a href$="[[_computeGroupPath(item)]]">
+                  [[item.name]]
+                </a>
+              </td>
               <td>[[item.description]]</td>
               <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
             </tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
index d26482d..8ca8598 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -32,5 +32,11 @@
     _computeVisibleToAll(group) {
       return group.options.visible_to_all ? 'Yes' : 'No';
     },
+
+    _computeGroupPath(group) {
+      if (!group || !group.id) { return; }
+
+      return Gerrit.Nav.getUrlForGroup(group.id);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
index 3c4eff2..812b186 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -33,10 +33,12 @@
 
 <script>
   suite('gr-group-list tests', () => {
+    let sandbox;
     let element;
     let groups;
 
     setup(done => {
+      sandbox = sinon.sandbox.create();
       groups = [{
         url: 'some url',
         options: {},
@@ -65,13 +67,15 @@
       element.loadData().then(() => { flush(done); });
     });
 
+    teardown(() => { sandbox.restore(); });
+
     test('renders', () => {
       const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
 
       assert.equal(rows.length, 3);
 
       const nameCells = rows.map(row =>
-        row.querySelectorAll('td')[0].textContent
+        row.querySelectorAll('td a')[0].textContent.trim()
       );
 
       assert.equal(nameCells[0], 'Group 1');
@@ -83,5 +87,23 @@
       assert.equal(element._computeVisibleToAll(groups[0]), 'No');
       assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
     });
+
+    test('_computeGroupPath', () => {
+      sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
+          () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+      let group = {
+        id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+      };
+
+      assert.equal(element._computeGroupPath(group),
+          '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+      group = {
+        name: 'admin',
+      };
+
+      assert.isUndefined(element._computeGroupPath(group));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index cc35e08..bebb1e8 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -45,11 +45,10 @@
       }
       .text {
         display: inline-block;
-        max-width: calc(100vw - 2.5rem);
-        overflow: hidden;
-        text-overflow: ellipsis;
+        max-height: 10rem;
+        max-width: 80vw;
         vertical-align: bottom;
-        white-space: nowrap;
+        word-break: break-all;
       }
       .action {
         color: #a1c2fa;