Merge "Add debug log that shows up in trace when an operation is retried"
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 1aa6cd7c..ff43520 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -374,6 +374,15 @@
 link:access-control.html#reference[here], but must not contain `${username}` or
 `${shardeduserid}`.
 
+[[label_ignoreSelfApproval]]
+=== `label.Label-Name.ignoreSelfApproval`
+
+If true, the label may be voted on by the uploader of the latest patch set,
+but their approval does not make a change submittable. Instead, a
+non-uploader who has the right to vote has to approve the change.
+
+Defaults to false.
+
 [[label_example]]
 === Example
 
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 97cca38..8f6a47b 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -210,6 +210,15 @@
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?trace&q=J
 ----
 
+Alternatively request tracing can also be enabled by setting the
+`X-Gerrit-Trace` header:
+
+.Example Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?q=J
+  X-Gerrit-Trace: issue/123
+----
+
 Enabling tracing results in additional logs with debug information that
 are written to the `error_log`. All logs that correspond to the traced
 request are associated with the trace ID. The trace ID is returned with
diff --git a/Documentation/user-request-tracing.txt b/Documentation/user-request-tracing.txt
index ac09d3c..8430e97 100644
--- a/Documentation/user-request-tracing.txt
+++ b/Documentation/user-request-tracing.txt
@@ -11,10 +11,10 @@
 request type:
 
 * REST API: For REST calls tracing can be enabled by setting the
-  `trace` request parameter, the trace ID is returned as
-  `X-Gerrit-Trace` header. More information about this can be found in
-  the link:rest-api.html#tracing[Request Tracing] section of the
-  link:rest-api.html[REST API documentation].
+  `trace` request parameter or the `X-Gerrit-Trace` header, the trace
+  ID is returned as `X-Gerrit-Trace` header. More information about
+  this can be found in the link:rest-api.html#tracing[Request Tracing]
+  section of the link:rest-api.html[REST API documentation].
 * SSH API: For SSH calls tracing can be enabled by setting the
   `--trace` option. More information about this can be found in
   the link:cmd-index.html#trace[Trace] section of the
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 1d87880..69d603f 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1487,12 +1487,7 @@
     assertNotifyTo(expected.email, expected.fullName);
   }
 
-  protected void assertNotifyTo(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
-    assertNotifyTo(expected.preferredEmail().orElse(null), expected.fullname().orElse(null));
-  }
-
-  private void assertNotifyTo(String expectedEmail, String expectedFullname) {
+  protected void assertNotifyTo(String expectedEmail, String expectedFullname) {
     Address expectedAddress = new Address(expectedFullname, expectedEmail);
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
@@ -1506,11 +1501,6 @@
     assertNotifyCc(expected.emailAddress);
   }
 
-  protected void assertNotifyCc(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
-    assertNotifyCc(expected.preferredEmail().orElse(null), expected.fullname().orElse(null));
-  }
-
   protected void assertNotifyCc(String expectedEmail, String expectedFullname) {
     Address expectedAddress = new Address(expectedFullname, expectedEmail);
     assertNotifyCc(expectedAddress);
@@ -1533,13 +1523,10 @@
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
 
-  protected void assertNotifyBcc(
-      com.google.gerrit.acceptance.testsuite.account.TestAccount expected) {
+  protected void assertNotifyBcc(String expectedEmail, String expectedFullName) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt())
-        .containsExactly(
-            new Address(expected.fullname().orElse(null), expected.preferredEmail().orElse(null)));
+    assertThat(m.rcpt()).containsExactly(new Address(expectedFullName, expectedEmail));
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 6e5424c..582c7cb 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -24,6 +24,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -441,6 +443,7 @@
             bindConstant().annotatedWith(SshEnabled.class).to(daemon.getEnableSshd());
             bind(AccountCreator.class);
             bind(AccountOperations.class).to(AccountOperationsImpl.class);
+            bind(GroupOperations.class).to(GroupOperationsImpl.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
index 6c03793..3e98d71 100644
--- a/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -14,13 +14,16 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.nio.ByteBuffer;
+import java.util.Arrays;
 import org.apache.http.Header;
 import org.eclipse.jgit.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -61,6 +64,13 @@
     return hdr != null ? hdr.getValue() : null;
   }
 
+  public ImmutableList<String> getHeaders(String name) {
+    return Arrays.asList(response.getHeaders(name))
+        .stream()
+        .map(Header::getValue)
+        .collect(toImmutableList());
+  }
+
   public boolean hasContent() {
     Preconditions.checkNotNull(response, "Response is not initialized.");
     return response.getEntity() != null;
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
index 58a00d0..61b7599 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
@@ -42,7 +42,7 @@
    * <p>Example:
    *
    * <pre>
-   * TestAccount createdAccount = accountOperations
+   * Account.Id createdAccountId = accountOperations
    *     .newAccount()
    *     .username("janedoe")
    *     .preferredEmail("janedoe@example.com")
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 94b511b..ebbcfe4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -59,12 +59,12 @@
     return TestAccountCreation.builder(this::createAccount);
   }
 
-  private TestAccount createAccount(TestAccountCreation accountCreation) throws Exception {
+  private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
     AccountsUpdate.AccountUpdater accountUpdater =
         (account, updateBuilder) ->
             fillBuilder(updateBuilder, accountCreation, account.getAccount().getId());
     AccountState createdAccount = createAccount(accountUpdater);
-    return toTestAccount(createdAccount);
+    return createdAccount.getAccount().getId();
   }
 
   private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
@@ -85,17 +85,6 @@
     accountCreation.active().ifPresent(builder::setActive);
   }
 
-  private static TestAccount toTestAccount(AccountState accountState) {
-    Account createdAccount = accountState.getAccount();
-    return TestAccount.builder()
-        .accountId(createdAccount.getId())
-        .preferredEmail(Optional.ofNullable(createdAccount.getPreferredEmail()))
-        .fullname(Optional.ofNullable(createdAccount.getFullName()))
-        .username(accountState.getUserName())
-        .active(accountState.getAccount().isActive())
-        .build();
-  }
-
   private static InternalAccountUpdate.Builder setPreferredEmail(
       InternalAccountUpdate.Builder builder, Account.Id accountId, String preferredEmail) {
     return builder
@@ -133,6 +122,17 @@
       return toTestAccount(account);
     }
 
+    private TestAccount toTestAccount(AccountState accountState) {
+      Account account = accountState.getAccount();
+      return TestAccount.builder()
+          .accountId(account.getId())
+          .preferredEmail(Optional.ofNullable(account.getPreferredEmail()))
+          .fullname(Optional.ofNullable(account.getFullName()))
+          .username(accountState.getUserName())
+          .active(accountState.getAccount().isActive())
+          .build();
+    }
+
     @Override
     public TestAccountUpdate.Builder forUpdate() {
       return TestAccountUpdate.builder(this::updateAccount);
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
index a82d180..ab32409 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.reviewdb.client.Account;
 import java.util.Optional;
 
 @AutoValue
@@ -32,9 +33,9 @@
 
   public abstract Optional<Boolean> active();
 
-  abstract ThrowingFunction<TestAccountCreation, TestAccount> accountCreator();
+  abstract ThrowingFunction<TestAccountCreation, Account.Id> accountCreator();
 
-  public static Builder builder(ThrowingFunction<TestAccountCreation, TestAccount> accountCreator) {
+  public static Builder builder(ThrowingFunction<TestAccountCreation, Account.Id> accountCreator) {
     return new AutoValue_TestAccountCreation.Builder()
         .accountCreator(accountCreator)
         .httpPassword("http-pass");
@@ -83,11 +84,11 @@
     }
 
     abstract Builder accountCreator(
-        ThrowingFunction<TestAccountCreation, TestAccount> accountCreator);
+        ThrowingFunction<TestAccountCreation, Account.Id> accountCreator);
 
     abstract TestAccountCreation autoBuild();
 
-    public TestAccount create() throws Exception {
+    public Account.Id create() throws Exception {
       TestAccountCreation accountUpdate = autoBuild();
       return accountUpdate.accountCreator().apply(accountUpdate);
     }
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
new file mode 100644
index 0000000..f75ca2e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperations.java
@@ -0,0 +1,97 @@
+// 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.testsuite.group;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+
+/**
+ * An aggregation of operations on groups for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ *
+ * <p><strong>Note:</strong> This interface is not implemented using the REST or extension API.
+ * Hence, it cannot be used for testing those APIs.
+ */
+public interface GroupOperations {
+  /**
+   * Starts the fluent chain for querying or modifying a group. Please see the methods of {@link
+   * MoreGroupOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific group
+   */
+  MoreGroupOperations group(AccountGroup.UUID groupUuid);
+
+  /**
+   * Starts the fluent chain to create a group. The returned builder can be used to specify the
+   * attributes of the new group. To create the group for real, {@link
+   * TestGroupCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * AccountGroup.UUID createdGroupUuid = groupOperations
+   *     .newGroup()
+   *     .name("verifiers")
+   *     .description("All verifiers of this server")
+   *     .create();
+   * </pre>
+   *
+   * <p><strong>Note:</strong> If another group with the provided name already exists, the creation
+   * of the group will fail.
+   *
+   * @return a builder to create the new group
+   */
+  TestGroupCreation.Builder newGroup();
+
+  /** An aggregation of methods on a specific group. */
+  interface MoreGroupOperations {
+
+    /**
+     * Checks whether the group exists.
+     *
+     * @return {@code true} if the group exists
+     */
+    boolean exists() throws Exception;
+
+    /**
+     * Retrieves the group.
+     *
+     * <p><strong>Note:</strong> This call will fail with an exception if the requested group
+     * doesn't exist. If you want to check for the existence of a group, use {@link #exists()}
+     * instead.
+     *
+     * @return the corresponding {@code TestGroup}
+     */
+    TestGroup get() throws Exception;
+
+    /**
+     * Starts the fluent chain to update a group. The returned builder can be used to specify how
+     * the attributes of the group should be modified. To update the group for real, {@link
+     * TestGroupUpdate.Builder#update()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * groupOperations.forUpdate().description("Another description for this group").update();
+     * </pre>
+     *
+     * <p><strong>Note:</strong> The update will fail with an exception if the group to update
+     * doesn't exist. If you want to check for the existence of a group, use {@link #exists()}.
+     *
+     * @return a builder to update the group
+     */
+    TestGroupUpdate.Builder forUpdate();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
new file mode 100644
index 0000000..f9769c5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -0,0 +1,159 @@
+// 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.testsuite.group;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * The implementation of {@code GroupOperations}.
+ *
+ * <p>There is only one implementation of {@code GroupOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class GroupOperationsImpl implements GroupOperations {
+  private final Groups groups;
+  private final GroupsUpdate groupsUpdate;
+  private final Sequences seq;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  public GroupOperationsImpl(
+      Groups groups,
+      @ServerInitiated GroupsUpdate groupsUpdate,
+      Sequences seq,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    this.groups = groups;
+    this.groupsUpdate = groupsUpdate;
+    this.seq = seq;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  public MoreGroupOperations group(AccountGroup.UUID groupUuid) {
+    return new MoreGroupOperationsImpl(groupUuid);
+  }
+
+  @Override
+  public TestGroupCreation.Builder newGroup() {
+    return TestGroupCreation.builder(this::createNewGroup);
+  }
+
+  private AccountGroup.UUID createNewGroup(TestGroupCreation groupCreation)
+      throws ConfigInvalidException, IOException, OrmException {
+    InternalGroupCreation internalGroupCreation = toInternalGroupCreation(groupCreation);
+    InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupCreation);
+    InternalGroup internalGroup =
+        groupsUpdate.createGroup(internalGroupCreation, internalGroupUpdate);
+    return internalGroup.getGroupUUID();
+  }
+
+  private InternalGroupCreation toInternalGroupCreation(TestGroupCreation groupCreation)
+      throws OrmException {
+    AccountGroup.Id groupId = new AccountGroup.Id(seq.nextGroupId());
+    String groupName = groupCreation.name().orElse("group-with-id-" + groupId.get());
+    AccountGroup.UUID groupUuid = GroupUUID.make(groupName, serverIdent);
+    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(groupName);
+    return InternalGroupCreation.builder()
+        .setId(groupId)
+        .setGroupUUID(groupUuid)
+        .setNameKey(nameKey)
+        .build();
+  }
+
+  private static InternalGroupUpdate toInternalGroupUpdate(TestGroupCreation groupCreation) {
+    InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+    groupCreation.description().ifPresent(builder::setDescription);
+    groupCreation.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
+    groupCreation.visibleToAll().ifPresent(builder::setVisibleToAll);
+    builder.setMemberModification(originalMembers -> groupCreation.members());
+    builder.setSubgroupModification(originalSubgroups -> groupCreation.subgroups());
+    return builder.build();
+  }
+
+  private class MoreGroupOperationsImpl implements MoreGroupOperations {
+    private final AccountGroup.UUID groupUuid;
+
+    MoreGroupOperationsImpl(AccountGroup.UUID groupUuid) {
+      this.groupUuid = groupUuid;
+    }
+
+    @Override
+    public boolean exists() throws Exception {
+      return groups.getGroup(groupUuid).isPresent();
+    }
+
+    @Override
+    public TestGroup get() throws Exception {
+      Optional<InternalGroup> group = groups.getGroup(groupUuid);
+      checkState(group.isPresent(), "Tried to get non-existing test group");
+      return toTestGroup(group.get());
+    }
+
+    private TestGroup toTestGroup(InternalGroup internalGroup) {
+      return TestGroup.builder()
+          .groupUuid(internalGroup.getGroupUUID())
+          .groupId(internalGroup.getId())
+          .nameKey(internalGroup.getNameKey())
+          .description(Optional.ofNullable(internalGroup.getDescription()))
+          .ownerGroupUuid(internalGroup.getOwnerGroupUUID())
+          .visibleToAll(internalGroup.isVisibleToAll())
+          .createdOn(internalGroup.getCreatedOn())
+          .members(internalGroup.getMembers())
+          .subgroups(internalGroup.getSubgroups())
+          .build();
+    }
+
+    @Override
+    public TestGroupUpdate.Builder forUpdate() {
+      return TestGroupUpdate.builder(this::updateGroup);
+    }
+
+    private void updateGroup(TestGroupUpdate groupUpdate)
+        throws OrmDuplicateKeyException, NoSuchGroupException, ConfigInvalidException, IOException {
+      InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupUpdate);
+      groupsUpdate.updateGroup(groupUuid, internalGroupUpdate);
+    }
+
+    private InternalGroupUpdate toInternalGroupUpdate(TestGroupUpdate groupUpdate) {
+      InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+      groupUpdate.name().map(AccountGroup.NameKey::new).ifPresent(builder::setName);
+      groupUpdate.description().ifPresent(builder::setDescription);
+      groupUpdate.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
+      groupUpdate.visibleToAll().ifPresent(builder::setVisibleToAll);
+      builder.setMemberModification(groupUpdate.memberModification()::apply);
+      builder.setSubgroupModification(groupUpdate.subgroupModification()::apply);
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
new file mode 100644
index 0000000..b450304
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
@@ -0,0 +1,78 @@
+// 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.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestGroup {
+
+  public abstract AccountGroup.UUID groupUuid();
+
+  public abstract AccountGroup.Id groupId();
+
+  public String name() {
+    return nameKey().get();
+  }
+
+  public abstract AccountGroup.NameKey nameKey();
+
+  public abstract Optional<String> description();
+
+  public abstract AccountGroup.UUID ownerGroupUuid();
+
+  public abstract boolean visibleToAll();
+
+  public abstract Timestamp createdOn();
+
+  public abstract ImmutableSet<Account.Id> members();
+
+  public abstract ImmutableSet<AccountGroup.UUID> subgroups();
+
+  static Builder builder() {
+    return new AutoValue_TestGroup.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+
+    public abstract Builder groupUuid(AccountGroup.UUID groupUuid);
+
+    public abstract Builder groupId(AccountGroup.Id id);
+
+    public abstract Builder nameKey(AccountGroup.NameKey name);
+
+    public abstract Builder description(String description);
+
+    public abstract Builder description(Optional<String> description);
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUuid);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    public abstract Builder createdOn(Timestamp createdOn);
+
+    public abstract Builder members(ImmutableSet<Account.Id> members);
+
+    public abstract Builder subgroups(ImmutableSet<AccountGroup.UUID> subgroups);
+
+    abstract TestGroup build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
new file mode 100644
index 0000000..efed720
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
@@ -0,0 +1,112 @@
+// 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.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Optional;
+import java.util.Set;
+
+@AutoValue
+public abstract class TestGroupCreation {
+
+  public abstract Optional<String> name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<AccountGroup.UUID> ownerGroupUuid();
+
+  public abstract Optional<Boolean> visibleToAll();
+
+  public abstract ImmutableSet<Account.Id> members();
+
+  public abstract ImmutableSet<AccountGroup.UUID> subgroups();
+
+  abstract ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator();
+
+  public static Builder builder(
+      ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator) {
+    return new AutoValue_TestGroupCreation.Builder().groupCreator(groupCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public Builder clearDescription() {
+      return description("");
+    }
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUuid);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    public Builder clearMembers() {
+      return members(ImmutableSet.of());
+    }
+
+    public Builder members(Account.Id member1, Account.Id... otherMembers) {
+      return members(Sets.union(ImmutableSet.of(member1), ImmutableSet.copyOf(otherMembers)));
+    }
+
+    public abstract Builder members(Set<Account.Id> members);
+
+    abstract ImmutableSet.Builder<Account.Id> membersBuilder();
+
+    public Builder addMember(Account.Id member) {
+      membersBuilder().add(member);
+      return this;
+    }
+
+    public Builder clearSubgroups() {
+      return subgroups(ImmutableSet.of());
+    }
+
+    public Builder subgroups(AccountGroup.UUID subgroup1, AccountGroup.UUID... otherSubgroups) {
+      return subgroups(Sets.union(ImmutableSet.of(subgroup1), ImmutableSet.copyOf(otherSubgroups)));
+    }
+
+    public abstract Builder subgroups(Set<AccountGroup.UUID> subgroups);
+
+    abstract ImmutableSet.Builder<AccountGroup.UUID> subgroupsBuilder();
+
+    public Builder addSubgroup(AccountGroup.UUID subgroup) {
+      subgroupsBuilder().add(subgroup);
+      return this;
+    }
+
+    abstract Builder groupCreator(
+        ThrowingFunction<TestGroupCreation, AccountGroup.UUID> groupCreator);
+
+    abstract TestGroupCreation autoBuild();
+
+    /**
+     * Executes the group creation as specified.
+     *
+     * @return the UUID of the created group
+     */
+    public AccountGroup.UUID create() throws Exception {
+      TestGroupCreation groupCreation = autoBuild();
+      return groupCreation.groupCreator().apply(groupCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
new file mode 100644
index 0000000..095a270
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
@@ -0,0 +1,134 @@
+// 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.testsuite.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+
+@AutoValue
+public abstract class TestGroupUpdate {
+
+  public abstract Optional<String> name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<AccountGroup.UUID> ownerGroupUuid();
+
+  public abstract Optional<Boolean> visibleToAll();
+
+  public abstract Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification();
+
+  public abstract Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>>
+      subgroupModification();
+
+  abstract ThrowingConsumer<TestGroupUpdate> groupUpdater();
+
+  public static Builder builder(ThrowingConsumer<TestGroupUpdate> groupUpdater) {
+    return new AutoValue_TestGroupUpdate.Builder()
+        .groupUpdater(groupUpdater)
+        .memberModification(in -> in)
+        .subgroupModification(in -> in);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public Builder clearDescription() {
+      return description("");
+    }
+
+    public abstract Builder ownerGroupUuid(AccountGroup.UUID ownerGroupUUID);
+
+    public abstract Builder visibleToAll(boolean visibleToAll);
+
+    abstract Builder memberModification(
+        Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification);
+
+    abstract Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification();
+
+    public Builder clearMembers() {
+      return memberModification(originalMembers -> ImmutableSet.of());
+    }
+
+    public Builder addMember(Account.Id member) {
+      Function<ImmutableSet<Account.Id>, Set<Account.Id>> previousModification =
+          memberModification();
+      memberModification(
+          originalMembers ->
+              Sets.union(previousModification.apply(originalMembers), ImmutableSet.of(member)));
+      return this;
+    }
+
+    public Builder removeMember(Account.Id member) {
+      Function<ImmutableSet<Account.Id>, Set<Account.Id>> previousModification =
+          memberModification();
+      memberModification(
+          originalMembers ->
+              Sets.difference(
+                  previousModification.apply(originalMembers), ImmutableSet.of(member)));
+      return this;
+    }
+
+    abstract Builder subgroupModification(
+        Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> subgroupModification);
+
+    abstract Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>>
+        subgroupModification();
+
+    public Builder clearSubgroups() {
+      return subgroupModification(originalMembers -> ImmutableSet.of());
+    }
+
+    public Builder addSubgroup(AccountGroup.UUID subgroup) {
+      Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> previousModification =
+          subgroupModification();
+      subgroupModification(
+          originalSubgroups ->
+              Sets.union(previousModification.apply(originalSubgroups), ImmutableSet.of(subgroup)));
+      return this;
+    }
+
+    public Builder removeSubgroup(AccountGroup.UUID subgroup) {
+      Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> previousModification =
+          subgroupModification();
+      subgroupModification(
+          originalSubgroups ->
+              Sets.difference(
+                  previousModification.apply(originalSubgroups), ImmutableSet.of(subgroup)));
+      return this;
+    }
+
+    abstract Builder groupUpdater(ThrowingConsumer<TestGroupUpdate> groupUpdater);
+
+    abstract TestGroupUpdate autoBuild();
+
+    /** Executes the group update as specified. */
+    public void update() throws Exception {
+      TestGroupUpdate groupUpdater = autoBuild();
+      groupUpdater.groupUpdater().accept(groupUpdater);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index ef10c22..ff7d25b 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -36,6 +36,7 @@
   public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
   public static final boolean DEF_COPY_MAX_SCORE = false;
   public static final boolean DEF_COPY_MIN_SCORE = false;
+  public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
     checkName(name);
@@ -103,6 +104,7 @@
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
   protected boolean allowPostSubmit;
+  protected boolean ignoreSelfApproval;
   protected short defaultValue;
 
   protected List<LabelValue> values;
@@ -141,6 +143,7 @@
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
     setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
+    setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
 
     byValue = new HashMap<>();
     for (LabelValue v : values) {
@@ -188,6 +191,14 @@
     this.allowPostSubmit = allowPostSubmit;
   }
 
+  public boolean ignoreSelfApproval() {
+    return ignoreSelfApproval;
+  }
+
+  public void setIgnoreSelfApproval(boolean ignoreSelfApproval) {
+    this.ignoreSelfApproval = ignoreSelfApproval;
+  }
+
   public void setRefPatterns(List<String> refPatterns) {
     if (refPatterns != null) {
       this.refPatterns =
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index 3e71098..fae7c6a 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -16,6 +16,7 @@
         "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/http",
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 38fe0e2..10f2638 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -110,6 +110,7 @@
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -1322,11 +1323,42 @@
   }
 
   private TraceContext enableTracing(HttpServletRequest req, HttpServletResponse res) {
-    String traceValue = req.getParameter(ParameterParser.TRACE_PARAMETER);
-    return TraceContext.newTrace(
-        traceValue != null,
-        traceValue,
-        (tagName, traceId) -> res.setHeader(X_GERRIT_TRACE, traceId.toString()));
+    // There are 2 ways to enable tracing for REST calls:
+    // 1. by using the 'trace' or 'trace=<trace-id>' request parameter
+    // 2. by setting the 'X-Gerrit-Trace:' or 'X-Gerrit-Trace:<trace-id>' header
+    String traceValueFromHeader = req.getHeader(X_GERRIT_TRACE);
+    String traceValueFromRequestParam = req.getParameter(ParameterParser.TRACE_PARAMETER);
+    boolean doTrace = traceValueFromHeader != null || traceValueFromRequestParam != null;
+
+    // Check whether no trace ID, one trace ID or 2 different trace IDs have been specified.
+    String traceId1;
+    String traceId2;
+    if (!Strings.isNullOrEmpty(traceValueFromHeader)) {
+      traceId1 = traceValueFromHeader;
+      if (!Strings.isNullOrEmpty(traceValueFromRequestParam)
+          && !traceValueFromHeader.equals(traceValueFromRequestParam)) {
+        traceId2 = traceValueFromRequestParam;
+      } else {
+        traceId2 = null;
+      }
+    } else {
+      traceId1 = Strings.emptyToNull(traceValueFromRequestParam);
+      traceId2 = null;
+    }
+
+    // Use the first trace ID to start tracing. If this trace ID is null, a trace ID will be
+    // generated.
+    TraceContext traceContext =
+        TraceContext.newTrace(
+            doTrace,
+            traceId1,
+            (tagName, traceId) -> res.setHeader(X_GERRIT_TRACE, traceId.toString()));
+    // If a second trace ID was specified, add a tag for it as well.
+    if (traceId2 != null) {
+      traceContext.addTag(RequestId.Type.TRACE_ID, traceId2);
+      res.addHeader(X_GERRIT_TRACE, traceId2);
+    }
+    return traceContext;
   }
 
   private boolean isDelete(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
index 6cb7751..9c6ba74 100644
--- a/java/com/google/gerrit/lucene/BUILD
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -33,6 +33,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:gwtorm",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 8d77ed8..683a205 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -75,6 +75,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -188,6 +189,7 @@
     factory(SubmitRuleEvaluator.Factory.class);
     install(new PrologModule());
     install(new DefaultSubmitRule.Module());
+    install(new IgnoreSelfApprovalRule.Module());
 
     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/BUILD b/java/com/google/gerrit/server/BUILD
index 96fcd39..e5bc480 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -42,6 +42,7 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/ssl",
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
index 2ce756b..f6418e3 100644
--- a/java/com/google/gerrit/server/cache/h2/BUILD
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:h2",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index e8c55e8..38c97f7 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -552,8 +552,6 @@
       return;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).project(ctx.getProject()).ref(refName);
     try {
       try (CommitReceivedEvent event =
           new CommitReceivedEvent(
@@ -565,7 +563,7 @@
               ctx.getIdentifiedUser())) {
         commitValidatorsFactory
             .forGerritCommits(
-                perm,
+                permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
                 new Branch.NameKey(ctx.getProject(), refName),
                 ctx.getIdentifiedUser(),
                 new NoSshInfo(),
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index e02f666..173d1da 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -1405,8 +1405,7 @@
         out.commitWithFooters =
             mergeUtilFactory
                 .create(projectCache.get(project))
-                .createCommitMessageOnSubmit(
-                    commit, mergeTip, cd.notes(), userProvider.get(), in.getId());
+                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.getId());
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index d71a93d..8bd6c17 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -323,9 +323,6 @@
       return;
     }
 
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).ref(origNotes.getChange().getDest());
-
     String refName = getPatchSetId().toRefName();
     try (CommitReceivedEvent event =
         new CommitReceivedEvent(
@@ -340,7 +337,7 @@
             ctx.getIdentifiedUser())) {
       commitValidatorsFactory
           .forGerritCommits(
-              perm,
+              permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
               origNotes.getChange().getDest(),
               ctx.getIdentifiedUser(),
               new NoSshInfo(),
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 909ea3a..1f216f0 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -165,8 +165,7 @@
       rw.parseBody(baseCommit);
       newCommitMessage =
           newMergeUtil()
-              .createCommitMessageOnSubmit(
-                  original, baseCommit, notes, changeOwner, originalPatchSet.getId());
+              .createCommitMessageOnSubmit(original, baseCommit, notes, originalPatchSet.getId());
     } else {
       newCommitMessage = original.getFullMessage();
     }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index b6a257b..0761d2e 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -170,6 +170,7 @@
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.rules.SubmitRule;
@@ -244,6 +245,7 @@
     install(new NoteDbModule(cfg));
     install(new PrologModule());
     install(new DefaultSubmitRule.Module());
+    install(new IgnoreSelfApprovalRule.Module());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
     install(ThreadLocalRequestContext.module());
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 686be19..c035269 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -41,7 +41,6 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -315,12 +314,10 @@
    *
    * @param n
    * @param notes
-   * @param user
    * @param psId
    * @return new message
    */
-  private String createDetailedCommitMessage(
-      RevCommit n, ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
+  private String createDetailedCommitMessage(RevCommit n, ChangeNotes notes, PatchSet.Id psId) {
     Change c = notes.getChange();
     final List<FooterLine> footers = n.getFooterLines();
     final StringBuilder msgbuf = new StringBuilder();
@@ -424,12 +421,7 @@
   }
 
   public String createCommitMessageOnSubmit(CodeReviewCommit n, RevCommit mergeTip) {
-    return createCommitMessageOnSubmit(
-        n,
-        mergeTip,
-        n.notes(),
-        identifiedUserFactory.create(n.notes().getChange().getOwner()),
-        n.getPatchsetId());
+    return createCommitMessageOnSubmit(n, mergeTip, n.notes(), n.getPatchsetId());
   }
 
   /**
@@ -442,14 +434,13 @@
    * @param n
    * @param mergeTip
    * @param notes
-   * @param user
    * @param id
    * @return new message
    */
   public String createCommitMessageOnSubmit(
-      RevCommit n, RevCommit mergeTip, ChangeNotes notes, CurrentUser user, Id id) {
+      RevCommit n, RevCommit mergeTip, ChangeNotes notes, Id id) {
     return commitMessageGenerator.generate(
-        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, user, id));
+        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, id));
   }
 
   private static boolean isCodeReview(LabelId id) {
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index fddb9d6..f1c604b 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index e9093cc..438438c 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -582,7 +582,7 @@
             tracePushOption.isPresent(),
             tracePushOption.orElse(null),
             (tagName, traceId) -> addMessage(tagName + ": " + traceId))) {
-      traceContext.addTag(RequestId.Type.RECEIVE_ID, RequestId.forProject(project.getNameKey()));
+      traceContext.addTag(RequestId.Type.RECEIVE_ID, new RequestId(project.getNameKey().get()));
 
       // Log the push options here, rather than in parsePushOptions(), so that they are included
       // into the trace if tracing is enabled.
@@ -1911,13 +1911,15 @@
     }
 
     try {
+      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, receivePack.getRevWalk());
       if (validCommit(
           receivePack.getRevWalk().getObjectReader(),
           changeEnt.getDest(),
           cmd,
           newCommit,
           false,
-          changeEnt)) {
+          changeEnt,
+          rejectCommits)) {
         logger.atFine().log("Replacing change %s", changeEnt.getId());
         requestReplace(cmd, true, changeEnt, newCommit);
       }
@@ -1966,6 +1968,10 @@
     }
   }
 
+  private NoteMap loadRejectCommits() throws IOException {
+    return BanCommit.loadRejectCommitsMap(repo, receivePack.getRevWalk());
+  }
+
   private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) {
     logger.atFine().log("Finding new and replaced changes");
     List<CreateRequest> newChanges = new ArrayList<>();
@@ -2079,7 +2085,8 @@
             magicBranch.cmd,
             c,
             magicBranch.merged,
-            null)) {
+            null,
+            loadRejectCommits())) {
           // Not a change the user can propose? Abort as early as possible.
           logger.atFine().log("Aborting early due to invalid commit");
           return Collections.emptyList();
@@ -3043,6 +3050,8 @@
     walk.reset();
     walk.sort(RevSort.NONE);
     try {
+      NoteMap rejectCommits = loadRejectCommits();
+
       RevObject parsedObject = walk.parseAny(cmd.getNewId());
       if (!(parsedObject instanceof RevCommit)) {
         return;
@@ -3065,7 +3074,7 @@
           continue;
         }
 
-        if (!validCommit(walk.getObjectReader(), branch, cmd, c, false, null)) {
+        if (!validCommit(walk.getObjectReader(), branch, cmd, c, false, null, rejectCommits)) {
           break;
         }
 
@@ -3102,9 +3111,9 @@
       ReceiveCommand cmd,
       RevCommit commit,
       boolean isMerged,
-      @Nullable Change change)
+      @Nullable Change change,
+      NoteMap rejectCommits)
       throws IOException {
-    PermissionBackend.ForRef perm = permissions.ref(branch.get());
 
     ValidCommitKey key = new AutoValue_ReceiveCommits_ValidCommitKey(commit.copy(), branch);
     if (validCommits.contains(key)) {
@@ -3113,18 +3122,21 @@
 
     try (CommitReceivedEvent receiveEvent =
         new CommitReceivedEvent(cmd, project, branch.get(), objectReader, commit, user)) {
-      CommitValidators validators =
-          isMerged
-              ? commitValidatorsFactory.forMergedCommits(
-                  project.getNameKey(), perm, user.asIdentifiedUser())
-              : commitValidatorsFactory.forReceiveCommits(
-                  perm,
-                  branch,
-                  user.asIdentifiedUser(),
-                  sshInfo,
-                  repo,
-                  receiveEvent.revWalk,
-                  change);
+      CommitValidators validators;
+      if (isMerged) {
+        validators =
+            commitValidatorsFactory.forMergedCommits(permissions, branch, user.asIdentifiedUser());
+      } else {
+        validators =
+            commitValidatorsFactory.forReceiveCommits(
+                permissions,
+                branch,
+                user.asIdentifiedUser(),
+                sshInfo,
+                rejectCommits,
+                receiveEvent.revWalk,
+                change);
+      }
 
       for (CommitValidationMessage m : validators.validate(receiveEvent)) {
         messages.add(
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 3e382fb..bcb910a 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Branch.NameKey;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -45,11 +44,9 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
@@ -123,15 +120,15 @@
     }
 
     public CommitValidators forReceiveCommits(
-        PermissionBackend.ForRef perm,
+        PermissionBackend.ForProject forProject,
         Branch.NameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
-        Repository repo,
+        NoteMap rejectCommits,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
-      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
       return new CommitValidators(
           ImmutableList.of(
@@ -157,13 +154,14 @@
     }
 
     public CommitValidators forGerritCommits(
-        ForRef perm,
+        PermissionBackend.ForProject forProject,
         NameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
       return new CommitValidators(
           ImmutableList.of(
@@ -187,7 +185,7 @@
     }
 
     public CommitValidators forMergedCommits(
-        Project.NameKey project, PermissionBackend.ForRef perm, IdentifiedUser user)
+        PermissionBackend.ForProject forProject, Branch.NameKey branch, IdentifiedUser user)
         throws IOException {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
@@ -202,10 +200,11 @@
       //    discuss what to do about it.
       //  - Plugin validators may do things like require certain commit message
       //    formats, so we play it safe and exclude them.
+      PermissionBackend.ForRef perm = forProject.ref(branch.get());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
-              new ProjectStateValidationListener(projectCache.checkedGet(project)),
+              new ProjectStateValidationListener(projectCache.checkedGet(branch.getParentKey())),
               new AuthorUploaderValidator(user, perm, canonicalWebUrl),
               new CommitterUploaderValidator(user, perm, canonicalWebUrl)));
     }
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
new file mode 100644
index 0000000..d3211f0
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -0,0 +1,13 @@
+java_library(
+    name = "logging",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//lib:guava",
+        "//lib/flogger:api",
+    ],
+)
diff --git a/java/com/google/gerrit/server/logging/RequestId.java b/java/com/google/gerrit/server/logging/RequestId.java
index 81619fb..b0a8ad9 100644
--- a/java/com/google/gerrit/server/logging/RequestId.java
+++ b/java/com/google/gerrit/server/logging/RequestId.java
@@ -19,8 +19,6 @@
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 
@@ -52,21 +50,13 @@
     return LoggingContext.getInstance().getTagsAsMap().keySet().stream().anyMatch(Type::isId);
   }
 
-  public static RequestId forChange(Change c) {
-    return new RequestId(c.getId().toString());
-  }
-
-  public static RequestId forProject(Project.NameKey p) {
-    return new RequestId(p.toString());
-  }
-
   private final String str;
 
   public RequestId() {
     this(null);
   }
 
-  private RequestId(@Nullable String resourceId) {
+  public RequestId(@Nullable String resourceId) {
     Hasher h = Hashing.murmur3_128().newHasher();
     h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
     str =
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 5b79ab9..bccc415 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -92,6 +92,7 @@
   public static final String KEY_DEFAULT_VALUE = "defaultValue";
   public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
+  public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
   public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
       "copyAllScoresOnMergeFirstParentUpdate";
@@ -883,6 +884,8 @@
       }
       label.setAllowPostSubmit(
           rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
+      label.setIgnoreSelfApproval(
+          rc.getBoolean(LABEL, name, KEY_IGNORE_SELF_APPROVAL, LabelType.DEF_IGNORE_SELF_APPROVAL));
       label.setCopyMinScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
       label.setCopyMaxScore(
@@ -1322,6 +1325,13 @@
           rc,
           LABEL,
           name,
+          KEY_IGNORE_SELF_APPROVAL,
+          label.ignoreSelfApproval(),
+          LabelType.DEF_IGNORE_SELF_APPROVAL);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
           KEY_COPY_MIN_SCORE,
           label.isCopyMinScore(),
           LabelType.DEF_COPY_MIN_SCORE);
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
new file mode 100644
index 0000000..b9ddbc6
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -0,0 +1,171 @@
+// 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 static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+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;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Rule to require an approval from a user that did not upload the current patch set or block
+ * submission.
+ */
+@Singleton
+public class IgnoreSelfApprovalRule implements SubmitRule {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String E_UNABLE_TO_FETCH_UPLOADER = "Unable to fetch uploader";
+  private static final String E_UNABLE_TO_FETCH_LABELS =
+      "Unable to fetch labels and approvals for the change";
+
+  public static class Module extends FactoryModule {
+    @Override
+    public void configure() {
+      bind(SubmitRule.class)
+          .annotatedWith(Exports.named("IgnoreSelfApprovalRule"))
+          .to(IgnoreSelfApprovalRule.class);
+    }
+  }
+
+  @Inject
+  IgnoreSelfApprovalRule() {}
+
+  @Override
+  public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions options) {
+    List<LabelType> labelTypes;
+    List<PatchSetApproval> approvals;
+    try {
+      labelTypes = cd.getLabelTypes().getLabelTypes();
+      approvals = cd.currentApprovals();
+    } catch (OrmException e) {
+      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_LABELS);
+      return singletonRuleError(E_UNABLE_TO_FETCH_LABELS);
+    }
+
+    boolean shouldIgnoreSelfApproval = labelTypes.stream().anyMatch(l -> l.ignoreSelfApproval());
+    if (!shouldIgnoreSelfApproval) {
+      // Shortcut to avoid further processing if no label should ignore uploader approvals
+      return ImmutableList.of();
+    }
+
+    Account.Id uploader;
+    try {
+      uploader = cd.currentPatchSet().getUploader();
+    } catch (OrmException e) {
+      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_UPLOADER);
+      return singletonRuleError(E_UNABLE_TO_FETCH_UPLOADER);
+    }
+
+    SubmitRecord submitRecord = new SubmitRecord();
+    submitRecord.status = SubmitRecord.Status.OK;
+    submitRecord.labels = new ArrayList<>(labelTypes.size());
+    submitRecord.requirements = new ArrayList<>();
+
+    for (LabelType t : labelTypes) {
+      if (!t.ignoreSelfApproval()) {
+        // The default rules are enough in this case.
+        continue;
+      }
+
+      LabelFunction labelFunction = t.getFunction();
+      if (labelFunction == null) {
+        continue;
+      }
+
+      Collection<PatchSetApproval> allApprovalsForLabel = filterApprovalsByLabel(approvals, t);
+      SubmitRecord.Label allApprovalsCheckResult = labelFunction.check(t, allApprovalsForLabel);
+      SubmitRecord.Label ignoreSelfApprovalCheckResult =
+          labelFunction.check(t, filterOutPositiveApprovalsOfUser(allApprovalsForLabel, uploader));
+
+      if (labelCheckPassed(allApprovalsCheckResult)
+          && !labelCheckPassed(ignoreSelfApprovalCheckResult)) {
+        // The label has a valid approval from the uploader and no other valid approval. Set the
+        // label
+        // to NOT_READY and indicate the need for non-uploader approval as requirement.
+        submitRecord.labels.add(ignoreSelfApprovalCheckResult);
+        submitRecord.status = SubmitRecord.Status.NOT_READY;
+        // Add an additional requirement to be more descriptive on why the label counts as not
+        // approved.
+        submitRecord.requirements.add(
+            SubmitRequirement.builder()
+                .setFallbackText("Approval from non-uploader required")
+                .setType("non_uploader_approval")
+                .build());
+      }
+    }
+
+    if (submitRecord.labels.isEmpty()) {
+      return ImmutableList.of();
+    }
+
+    return ImmutableList.of(submitRecord);
+  }
+
+  private static boolean labelCheckPassed(SubmitRecord.Label label) {
+    switch (label.status) {
+      case OK:
+      case MAY:
+        return true;
+
+      case NEED:
+      case REJECT:
+      case IMPOSSIBLE:
+        return false;
+    }
+    return false;
+  }
+
+  private static Collection<SubmitRecord> singletonRuleError(String reason) {
+    SubmitRecord submitRecord = new SubmitRecord();
+    submitRecord.errorMessage = reason;
+    submitRecord.status = SubmitRecord.Status.RULE_ERROR;
+    return ImmutableList.of(submitRecord);
+  }
+
+  @VisibleForTesting
+  static Collection<PatchSetApproval> filterOutPositiveApprovalsOfUser(
+      Collection<PatchSetApproval> approvals, Account.Id user) {
+    return approvals
+        .stream()
+        .filter(input -> input.getValue() < 0 || !input.getAccountId().equals(user))
+        .collect(toImmutableList());
+  }
+
+  @VisibleForTesting
+  static Collection<PatchSetApproval> filterApprovalsByLabel(
+      Collection<PatchSetApproval> approvals, LabelType t) {
+    return approvals
+        .stream()
+        .filter(input -> input.getLabelId().get().equals(t.getLabelId().get()))
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 9a8895b..43d5f75 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -457,7 +457,7 @@
     this.caller = caller;
     this.ts = TimeUtil.nowTs();
     this.db = db;
-    this.submissionId = RequestId.forChange(change);
+    this.submissionId = new RequestId(change.getId().toString());
 
     try (TraceContext traceContext =
         TraceContext.open().addTag(RequestId.Type.SUBMISSION_ID, submissionId)) {
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 4743b35..47b1d89 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/util/cli",
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index cf65908..15ceb77 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -29,6 +29,7 @@
         "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index b362b2b..7126354 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -526,9 +526,9 @@
 
   @Test
   public void validateAccountActivation() throws Exception {
-    com.google.gerrit.acceptance.testsuite.account.TestAccount activatableAccount =
+    Account.Id activatableAccountId =
         accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
-    com.google.gerrit.acceptance.testsuite.account.TestAccount deactivatableAccount =
+    Account.Id deactivatableAccountId =
         accountOperations.newAccount().preferredEmail("foo@deactivatable.com").create();
     RegistrationHandle registrationHandle =
         accountActivationValidationListeners.add(
@@ -554,61 +554,56 @@
       /* Test account that can be activated, but not deactivated */
       // Deactivate account that is already inactive
       try {
-        gApi.accounts().id(activatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(activatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("account not active");
       }
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
 
       // Activate account that can be activated
-      gApi.accounts().id(activatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      gApi.accounts().id(activatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       // Activate account that is already active
-      gApi.accounts().id(activatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      gApi.accounts().id(activatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       // Try deactivating account that cannot be deactivated
       try {
-        gApi.accounts().id(activatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(activatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("not allowed to deactive account");
       }
-      assertThat(accountOperations.account(activatableAccount.accountId()).get().active()).isTrue();
+      assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
 
       /* Test account that can be deactivated, but not activated */
       // Activate account that is already inactive
-      gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(true);
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isTrue();
+      gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
 
       // Deactivate account that can be deactivated
-      gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(false);
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Deactivate account that is already inactive
       try {
-        gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(false);
+        gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("account not active");
       }
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
 
       // Try activating account that cannot be activated
       try {
-        gApi.accounts().id(deactivatableAccount.accountId().get()).setActive(true);
+        gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
         fail("Expected exception");
       } catch (ResourceConflictException e) {
         assertThat(e.getMessage()).isEqualTo("not allowed to active account");
       }
-      assertThat(accountOperations.account(deactivatableAccount.accountId()).get().active())
-          .isFalse();
+      assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
     } finally {
       registrationHandle.remove();
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index fe55115..b3a5e2d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -68,7 +68,6 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
-import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelFunction;
@@ -1688,7 +1687,7 @@
     // create a group named "ab" with one user: testUser
     String email = "abcd@test.com";
     String fullname = "abcd";
-    TestAccount testUser =
+    Account.Id accountIdOfTestUser =
         accountOperations
             .newAccount()
             .username("abcd")
@@ -1721,7 +1720,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(testUser.accountId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfTestUser.get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -1748,7 +1747,7 @@
 
     String myGroupUserEmail = "lee@test.com";
     String myGroupUserFullname = "lee";
-    TestAccount myGroupUser =
+    Account.Id accountIdOfGroupUser =
         accountOperations
             .newAccount()
             .username("lee")
@@ -1785,7 +1784,7 @@
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(myGroupUser.accountId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfGroupUser.get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -2215,13 +2214,12 @@
 
     // notify unrelated account as TO
     String email = "user2@example.com";
-    TestAccount user2 =
-        accountOperations
-            .newAccount()
-            .username("user2")
-            .preferredEmail(email)
-            .fullname("User2")
-            .create();
+    accountOperations
+        .newAccount()
+        .username("user2")
+        .preferredEmail(email)
+        .fullname("User2")
+        .create();
     setApiUser(user);
     recommend(r.getChangeId());
     setApiUser(admin);
@@ -2229,7 +2227,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.TO, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyTo(user2);
+    assertNotifyTo(email, "User2");
 
     // notify unrelated account as CC
     setApiUser(user);
@@ -2239,7 +2237,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.CC, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyCc(user2);
+    assertNotifyCc(email, "User2");
 
     // notify unrelated account as BCC
     setApiUser(user);
@@ -2249,7 +2247,7 @@
     in.notifyDetails = new HashMap<>();
     in.notifyDetails.put(RecipientType.BCC, new NotifyInfo(ImmutableList.of(email)));
     gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyBcc(user2);
+    assertNotifyBcc(email, "User2");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 8f1bdbc..d6be960 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -183,24 +183,23 @@
   @Test
   public void cachedGroupsForMemberAreUpdatedOnMemberAdditionAndRemoval() throws Exception {
     String username = name("user");
-    com.google.gerrit.acceptance.testsuite.account.TestAccount account =
-        accountOperations.newAccount().username(username).create();
+    Account.Id accountId = accountOperations.newAccount().username(username).create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(account.accountId());
+    groupIncludeCache.getGroupsWithMember(accountId);
     String groupName = createGroup("users");
     AccountGroup.UUID groupUuid = new AccountGroup.UUID(gApi.groups().id(groupName).get().id);
 
     gApi.groups().id(groupName).addMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterAddition =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+        groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groupsWithMemberAfterAddition).contains(groupUuid);
 
     gApi.groups().id(groupName).removeMembers(username);
 
     Collection<AccountGroup.UUID> groupsWithMemberAfterRemoval =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+        groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groupsWithMemberAfterRemoval).doesNotContain(groupUuid);
   }
 
@@ -411,19 +410,17 @@
 
   @Test
   public void cachedGroupsForMemberAreUpdatedOnGroupCreation() throws Exception {
-    com.google.gerrit.acceptance.testsuite.account.TestAccount account =
-        accountOperations.newAccount().create();
+    Account.Id accountId = accountOperations.newAccount().create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(account.accountId());
+    groupIncludeCache.getGroupsWithMember(accountId);
 
     GroupInput groupInput = new GroupInput();
     groupInput.name = name("Users");
-    groupInput.members = ImmutableList.of(String.valueOf(account.accountId().get()));
+    groupInput.members = ImmutableList.of(String.valueOf(accountId.get()));
     GroupInfo group = gApi.groups().create(groupInput).get();
 
-    Collection<AccountGroup.UUID> groups =
-        groupIncludeCache.getGroupsWithMember(account.accountId());
+    Collection<AccountGroup.UUID> groups = groupIncludeCache.getGroupsWithMember(accountId);
     assertThat(groups).containsExactly(new AccountGroup.UUID(group.id));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 2b1416a..e4194a3 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -20,16 +20,18 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
@@ -39,35 +41,29 @@
 
 public class CheckAccessIT extends AbstractDaemonTest {
 
+  @Inject private GroupOperations groupOperations;
+
   private Project.NameKey normalProject;
   private Project.NameKey secretProject;
   private Project.NameKey secretRefProject;
   private TestAccount privilegedUser;
-  private InternalGroup privilegedGroup;
 
   @Before
   public void setUp() throws Exception {
     normalProject = createProject("normal");
     secretProject = createProject("secret");
     secretRefProject = createProject("secretRef");
-    privilegedGroup = group(createGroup("privilegedGroup"));
+    AccountGroup.UUID privilegedGroupUuid =
+        groupOperations.newGroup().name(name("privilegedGroup")).create();
 
     privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
-    gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
+    groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id).update();
 
-    assertThat(gApi.groups().id(privilegedGroup.getGroupUUID().get()).members().get(0).email)
-        .contains("snowden");
-
-    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroup.getGroupUUID());
+    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroupUuid);
     block(secretProject, "refs/*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
 
     deny(secretRefProject, "refs/*", Permission.READ, SystemGroupBackend.ANONYMOUS_USERS);
-    grant(
-        secretRefProject,
-        "refs/heads/secret/*",
-        Permission.READ,
-        false,
-        privilegedGroup.getGroupUUID());
+    grant(secretRefProject, "refs/heads/secret/*", Permission.READ, false, privilegedGroupUuid);
     block(
         secretRefProject,
         "refs/heads/secret/*",
@@ -81,13 +77,8 @@
         SystemGroupBackend.REGISTERED_USERS);
 
     // Ref permission
-    grant(
-        normalProject,
-        "refs/*",
-        Permission.VIEW_PRIVATE_CHANGES,
-        false,
-        privilegedGroup.getGroupUUID());
-    grant(normalProject, "refs/*", Permission.FORGE_SERVER, false, privilegedGroup.getGroupUUID());
+    grant(normalProject, "refs/*", Permission.VIEW_PRIVATE_CHANGES, false, privilegedGroupUuid);
+    grant(normalProject, "refs/*", Permission.FORGE_SERVER, false, privilegedGroupUuid);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/BUILD b/javatests/com/google/gerrit/acceptance/rest/BUILD
index b4940bc..b94a98d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/BUILD
@@ -4,7 +4,10 @@
     srcs = glob(["*IT.java"]),
     group = "rest_bindings",
     labels = ["rest"],
-    deps = [":util"],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/server/logging",
+    ],
 )
 
 java_library(
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index b48d47e..4de54e3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -18,6 +18,7 @@
 import static org.apache.http.HttpStatus.SC_CREATED;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import java.util.List;
+import org.apache.http.message.BasicHeader;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -71,29 +73,90 @@
     assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
     assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
     assertThat(projectCreationListener.traceId).isNull();
-    assertThat(projectCreationListener.foundTraceId).isFalse();
     assertThat(projectCreationListener.isLoggingForced).isFalse();
   }
 
   @Test
-  public void restCallWithTrace() throws Exception {
+  public void restCallWithTraceRequestParam() throws Exception {
     RestResponse response =
         adminRestSession.put("/projects/new2?" + ParameterParser.TRACE_PARAMETER);
     assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
     assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
     assertThat(projectCreationListener.traceId).isNotNull();
-    assertThat(projectCreationListener.foundTraceId).isTrue();
     assertThat(projectCreationListener.isLoggingForced).isTrue();
   }
 
   @Test
-  public void restCallWithTraceAndProvidedTraceId() throws Exception {
+  public void restCallWithTraceRequestParamAndProvidedTraceId() throws Exception {
     RestResponse response =
         adminRestSession.put("/projects/new3?" + ParameterParser.TRACE_PARAMETER + "=issue/123");
     assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
     assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
-    assertThat(projectCreationListener.foundTraceId).isTrue();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceHeader() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new4", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+    assertThat(projectCreationListener.traceId).isNotNull();
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceHeaderAndProvidedTraceId() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new5", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceRequestParamAndTraceHeader() throws Exception {
+    // trace ID only specified by trace header
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/projects/new6?trace", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // trace ID only specified by trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new7?trace=issue/123", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // same trace ID specified by trace header and trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new8?trace=issue/123",
+            new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
+    assertThat(projectCreationListener.traceId).isEqualTo("issue/123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+
+    // different trace IDs specified by trace header and trace request parameter
+    response =
+        adminRestSession.putWithHeader(
+            "/projects/new9?trace=issue/123",
+            new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/456"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeaders(RestApiServlet.X_GERRIT_TRACE))
+        .containsExactly("issue/123", "issue/456");
+    assertThat(projectCreationListener.traceIds).containsExactly("issue/123", "issue/456");
     assertThat(projectCreationListener.isLoggingForced).isTrue();
   }
 
@@ -103,7 +166,6 @@
     PushOneCommit.Result r = push.to("refs/heads/master");
     r.assertOkStatus();
     assertThat(commitValidationListener.traceId).isNull();
-    assertThat(commitValidationListener.foundTraceId).isFalse();
     assertThat(commitValidationListener.isLoggingForced).isFalse();
   }
 
@@ -114,7 +176,6 @@
     PushOneCommit.Result r = push.to("refs/heads/master");
     r.assertOkStatus();
     assertThat(commitValidationListener.traceId).isNotNull();
-    assertThat(commitValidationListener.foundTraceId).isTrue();
     assertThat(commitValidationListener.isLoggingForced).isTrue();
   }
 
@@ -125,7 +186,6 @@
     PushOneCommit.Result r = push.to("refs/heads/master");
     r.assertOkStatus();
     assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
-    assertThat(commitValidationListener.foundTraceId).isTrue();
     assertThat(commitValidationListener.isLoggingForced).isTrue();
   }
 
@@ -135,7 +195,6 @@
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     assertThat(commitValidationListener.traceId).isNull();
-    assertThat(commitValidationListener.foundTraceId).isFalse();
     assertThat(commitValidationListener.isLoggingForced).isFalse();
   }
 
@@ -146,7 +205,6 @@
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     assertThat(commitValidationListener.traceId).isNotNull();
-    assertThat(commitValidationListener.foundTraceId).isTrue();
     assertThat(commitValidationListener.isLoggingForced).isTrue();
   }
 
@@ -157,28 +215,26 @@
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     assertThat(commitValidationListener.traceId).isEqualTo("issue/123");
-    assertThat(commitValidationListener.foundTraceId).isTrue();
     assertThat(commitValidationListener.isLoggingForced).isTrue();
   }
 
   private static class TraceValidatingProjectCreationValidationListener
       implements ProjectCreationValidationListener {
     String traceId;
-    Boolean foundTraceId;
+    ImmutableSet<String> traceIds;
     Boolean isLoggingForced;
 
     @Override
     public void validateNewProject(CreateProjectArgs args) throws ValidationException {
       this.traceId =
           Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
-      this.foundTraceId = traceId != null;
+      this.traceIds = LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID");
       this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
     }
   }
 
   private static class TraceValidatingCommitValidationListener implements CommitValidationListener {
     String traceId;
-    Boolean foundTraceId;
     Boolean isLoggingForced;
 
     @Override
@@ -186,7 +242,6 @@
         throws CommitValidationException {
       this.traceId =
           Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
-      this.foundTraceId = traceId != null;
       this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
       return ImmutableList.of();
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
new file mode 100644
index 0000000..d1b05e3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -0,0 +1,99 @@
+// 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.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+@NoHttpd
+public class IgnoreSelfApprovalRuleIT extends AbstractDaemonTest {
+  @Inject private IgnoreSelfApprovalRule rule;
+
+  @Test
+  public void blocksWhenUploaderIsOnlyApprover() throws Exception {
+    enableRule("Code-Review", true);
+
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+
+    assertThat(submitRecords).hasSize(1);
+    SubmitRecord result = submitRecords.iterator().next();
+    assertThat(result.status).isEqualTo(SubmitRecord.Status.NOT_READY);
+    assertThat(result.labels).isNotEmpty();
+    assertThat(result.requirements)
+        .containsExactly(
+            SubmitRequirement.builder()
+                .setFallbackText("Approval from non-uploader required")
+                .setType("non_uploader_approval")
+                .build());
+  }
+
+  @Test
+  public void allowsSubmissionWhenChangeHasNonUploaderApproval() throws Exception {
+    enableRule("Code-Review", true);
+
+    // Create change as user
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    // Approve as admin
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    assertThat(submitRecords).isEmpty();
+  }
+
+  @Test
+  public void doesNothingByDefault() throws Exception {
+    enableRule("Code-Review", false);
+
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+
+    Collection<SubmitRecord> submitRecords =
+        rule.evaluate(r.getChange(), SubmitRuleOptions.defaults());
+    assertThat(submitRecords).isEmpty();
+  }
+
+  private void enableRule(String labelName, boolean newState) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Map<String, LabelType> localLabelSections = u.getConfig().getLabelSections();
+      if (localLabelSections.isEmpty()) {
+        localLabelSections.putAll(projectCache.getAllProjects().getConfig().getLabelSections());
+      }
+      localLabelSections.get(labelName).setIgnoreSelfApproval(newState);
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
index b195ecc..a01cd3e 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BUILD
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -17,6 +17,7 @@
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/server/logging",
         "//lib/commons:compress",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
new file mode 100644
index 0000000..954b0e6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -0,0 +1,656 @@
+// 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.testsuite.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.Objects;
+import java.util.Optional;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class GroupOperationsImplTest extends AbstractDaemonTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  @Inject private AccountOperations accountOperations;
+
+  @Inject private GroupOperationsImpl groupOperations;
+
+  private int uniqueGroupNameIndex;
+
+  @Test
+  public void groupCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.id).isEqualTo(groupUuid.get());
+    assertThat(foundGroup.name).isNotEmpty();
+  }
+
+  @Test
+  public void twoGroupsWithoutAnyParametersDoNotClash() throws Exception {
+    AccountGroup.UUID groupUuid1 = groupOperations.newGroup().create();
+    AccountGroup.UUID groupUuid2 = groupOperations.newGroup().create();
+
+    TestGroup group1 = groupOperations.group(groupUuid1).get();
+    TestGroup group2 = groupOperations.group(groupUuid2).get();
+    assertThat(group1.groupUuid()).isNotEqualTo(group2.groupUuid());
+  }
+
+  @Test
+  public void groupCreatedByTestApiCanBeRetrievedViaOfficialApi() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("unique group created via test API").create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.id).isEqualTo(groupUuid.get());
+    assertThat(foundGroup.name).isEqualTo("unique group created via test API");
+  }
+
+  @Test
+  public void specifiedNameIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("XYZ-123-this-name-must-be-unique").create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.name).isEqualTo("XYZ-123-this-name-must-be-unique");
+  }
+
+  @Test
+  public void specifiedDescriptionIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("All authenticated users").create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.description).isEqualTo("All authenticated users");
+  }
+
+  @Test
+  public void requestingNoDescriptionIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearDescription().create();
+
+    GroupInfo group = getGroupFromServer(groupUuid);
+    assertThat(group.description).isNull();
+  }
+
+  @Test
+  public void specifiedOwnerIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID ownerGroupUuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(ownerGroupUuid).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.ownerId).isEqualTo(ownerGroupUuid.get());
+  }
+
+  @Test
+  public void specifiedVisibilityIsRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().visibleToAll(true).create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().visibleToAll(false).create();
+
+    GroupInfo foundGroup1 = getGroupFromServer(group1Uuid);
+    GroupInfo foundGroup2 = getGroupFromServer(group2Uuid);
+    assertThat(foundGroup1.options.visibleToAll).isTrue();
+    // False == null
+    assertThat(foundGroup2.options.visibleToAll).isNull();
+  }
+
+  @Test
+  public void specifiedMembersAreRespectedForGroupCreation() throws Exception {
+    Account.Id account1Id = accountOperations.newAccount().create();
+    Account.Id account2Id = accountOperations.newAccount().create();
+    Account.Id account3Id = accountOperations.newAccount().create();
+    Account.Id account4Id = accountOperations.newAccount().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .members(account1Id, account2Id)
+            .addMember(account3Id)
+            .addMember(account4Id)
+            .create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members)
+        .comparingElementsUsing(getAccountToIdCorrespondence())
+        .containsExactly(account1Id, account2Id, account3Id, account4Id);
+  }
+
+  @Test
+  public void directlyAddingMembersIsPossibleForGroupCreation() throws Exception {
+    Account.Id account1Id = accountOperations.newAccount().create();
+    Account.Id account2Id = accountOperations.newAccount().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().addMember(account1Id).addMember(account2Id).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members)
+        .comparingElementsUsing(getAccountToIdCorrespondence())
+        .containsExactly(account1Id, account2Id);
+  }
+
+  @Test
+  public void requestingNoMembersIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.members).isEmpty();
+  }
+
+  @Test
+  public void specifiedSubgroupsAreRespectedForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group3Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group4Uuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .subgroups(group1Uuid, group2Uuid)
+            .addSubgroup(group3Uuid)
+            .addSubgroup(group4Uuid)
+            .create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes)
+        .comparingElementsUsing(getGroupToUuidCorrespondence())
+        .containsExactly(group1Uuid, group2Uuid, group3Uuid, group4Uuid);
+  }
+
+  @Test
+  public void directlyAddingSubgroupsIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID group1Uuid = groupOperations.newGroup().create();
+    AccountGroup.UUID group2Uuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().addSubgroup(group1Uuid).addSubgroup(group2Uuid).create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes)
+        .comparingElementsUsing(getGroupToUuidCorrespondence())
+        .containsExactly(group1Uuid, group2Uuid);
+  }
+
+  @Test
+  public void requestingNoSubgroupsIsPossibleForGroupCreation() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    GroupInfo foundGroup = getGroupFromServer(groupUuid);
+    assertThat(foundGroup.includes).isEmpty();
+  }
+
+  @Test
+  public void existingGroupCanBeCheckedForExistence() throws Exception {
+    AccountGroup.UUID groupUuid = createGroupInServer(createArbitraryGroupInput());
+
+    boolean exists = groupOperations.group(groupUuid).exists();
+
+    assertThat(exists).isTrue();
+  }
+
+  @Test
+  public void notExistingGroupCanBeCheckedForExistence() throws Exception {
+    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
+
+    boolean exists = groupOperations.group(notExistingGroupUuid).exists();
+
+    assertThat(exists).isFalse();
+  }
+
+  @Test
+  public void retrievingNotExistingGroupFails() throws Exception {
+    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
+
+    expectedException.expect(IllegalStateException.class);
+    groupOperations.group(notExistingGroupUuid).get();
+  }
+
+  @Test
+  public void groupNotCreatedByTestApiCanBeRetrieved() throws Exception {
+    GroupInput input = createArbitraryGroupInput();
+    input.name = "unique group not created via test API";
+    AccountGroup.UUID groupUuid = createGroupInServer(input);
+
+    TestGroup foundGroup = groupOperations.group(groupUuid).get();
+
+    assertThat(foundGroup.groupUuid()).isEqualTo(groupUuid);
+    assertThat(foundGroup.name()).isEqualTo("unique group not created via test API");
+  }
+
+  @Test
+  public void uuidOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+
+    AccountGroup.UUID foundGroupUuid = groupOperations.group(groupUuid).get().groupUuid();
+
+    assertThat(foundGroupUuid).isEqualTo(groupUuid);
+  }
+
+  @Test
+  public void nameOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("ABC-789-this-name-must-be-unique").create();
+
+    String groupName = groupOperations.group(groupUuid).get().name();
+
+    assertThat(groupName).isEqualTo("ABC-789-this-name-must-be-unique");
+  }
+
+  @Test
+  public void nameKeyOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().name("ABC-789-this-name-must-be-unique").create();
+
+    AccountGroup.NameKey groupName = groupOperations.group(groupUuid).get().nameKey();
+
+    assertThat(groupName).isEqualTo(new AccountGroup.NameKey("ABC-789-this-name-must-be-unique"));
+  }
+
+  @Test
+  public void descriptionOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations
+            .newGroup()
+            .description("This is a very detailed description of this group.")
+            .create();
+
+    Optional<String> description = groupOperations.group(groupUuid).get().description();
+
+    assertThat(description).hasValue("This is a very detailed description of this group.");
+  }
+
+  @Test
+  public void emptyDescriptionOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearDescription().create();
+
+    Optional<String> description = groupOperations.group(groupUuid).get().description();
+
+    assertThat(description).isEmpty();
+  }
+
+  @Test
+  public void ownerGroupUuidOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("owner group");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
+
+    AccountGroup.UUID ownerGroupUuid = groupOperations.group(groupUuid).get().ownerGroupUuid();
+
+    assertThat(ownerGroupUuid).isEqualTo(originalOwnerGroupUuid);
+  }
+
+  @Test
+  public void visibilityOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID visibleGroupUuid = groupOperations.newGroup().visibleToAll(true).create();
+    AccountGroup.UUID invisibleGroupUuid = groupOperations.newGroup().visibleToAll(false).create();
+
+    TestGroup visibleGroup = groupOperations.group(visibleGroupUuid).get();
+    TestGroup invisibleGroup = groupOperations.group(invisibleGroupUuid).get();
+
+    assertThat(visibleGroup.visibleToAll()).named("visibility of visible group").isTrue();
+    assertThat(invisibleGroup.visibleToAll()).named("visibility of invisible group").isFalse();
+  }
+
+  @Test
+  public void createdOnOfExistingGroupCanBeRetrieved() throws Exception {
+    GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group.id);
+
+    Timestamp createdOn = groupOperations.group(groupUuid).get().createdOn();
+
+    assertThat(createdOn).isEqualTo(group.createdOn);
+  }
+
+  @Test
+  public void membersOfExistingGroupCanBeRetrieved() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId3 = new Account.Id(3000);
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().members(memberId1, memberId2, memberId3).create();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+
+    assertThat(members).containsExactly(memberId1, memberId2, memberId3);
+  }
+
+  @Test
+  public void emptyMembersOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+
+    assertThat(members).isEmpty();
+  }
+
+  @Test
+  public void subgroupsOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2, subgroupUuid3).create();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+
+    assertThat(subgroups).containsExactly(subgroupUuid1, subgroupUuid2, subgroupUuid3);
+  }
+
+  @Test
+  public void emptySubgroupsOfExistingGroupCanBeRetrieved() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+
+    assertThat(subgroups).isEmpty();
+  }
+
+  @Test
+  public void updateWithoutAnyParametersIsANoop() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
+    TestGroup originalGroup = groupOperations.group(groupUuid).get();
+
+    groupOperations.group(groupUuid).forUpdate().update();
+
+    TestGroup updatedGroup = groupOperations.group(groupUuid).get();
+    assertThat(updatedGroup).isEqualTo(originalGroup);
+  }
+
+  @Test
+  public void updateWritesToInternalGroupSystem() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().description("updated description").update();
+
+    String currentDescription = getGroupFromServer(groupUuid).description;
+    assertThat(currentDescription).isEqualTo("updated description");
+  }
+
+  @Test
+  public void nameCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().name("original name").create();
+
+    groupOperations.group(groupUuid).forUpdate().name("updated name").update();
+
+    String currentName = groupOperations.group(groupUuid).get().name();
+    assertThat(currentName).isEqualTo("updated name");
+  }
+
+  @Test
+  public void descriptionCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().description("updated description").update();
+
+    Optional<String> currentDescription = groupOperations.group(groupUuid).get().description();
+    assertThat(currentDescription).hasValue("updated description");
+  }
+
+  @Test
+  public void descriptionCanBeCleared() throws Exception {
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().description("original description").create();
+
+    groupOperations.group(groupUuid).forUpdate().clearDescription().update();
+
+    Optional<String> currentDescription = groupOperations.group(groupUuid).get().description();
+    assertThat(currentDescription).isEmpty();
+  }
+
+  @Test
+  public void ownerGroupUuidCanBeUpdated() throws Exception {
+    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("original owner");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
+
+    AccountGroup.UUID updatedOwnerGroupUuid = new AccountGroup.UUID("updated owner");
+    groupOperations.group(groupUuid).forUpdate().ownerGroupUuid(updatedOwnerGroupUuid).update();
+
+    AccountGroup.UUID currentOwnerGroupUuid =
+        groupOperations.group(groupUuid).get().ownerGroupUuid();
+    assertThat(currentOwnerGroupUuid).isEqualTo(updatedOwnerGroupUuid);
+  }
+
+  @Test
+  public void visibilityCanBeUpdated() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().visibleToAll(true).create();
+
+    groupOperations.group(groupUuid).forUpdate().visibleToAll(false).update();
+
+    boolean visibleToAll = groupOperations.group(groupUuid).get().visibleToAll();
+    assertThat(visibleToAll).isFalse();
+  }
+
+  @Test
+  public void membersCanBeAdded() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
+
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    groupOperations.group(groupUuid).forUpdate().addMember(memberId1).addMember(memberId2).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId1, memberId2);
+  }
+
+  @Test
+  public void membersCanBeRemoved() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    groupOperations.group(groupUuid).forUpdate().removeMember(memberId2).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId1);
+  }
+
+  @Test
+  public void memberAdditionAndRemovalCanBeMixed() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    Account.Id memberId3 = new Account.Id(3000);
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .removeMember(memberId1)
+        .addMember(memberId3)
+        .update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId2, memberId3);
+  }
+
+  @Test
+  public void membersCanBeCleared() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    groupOperations.group(groupUuid).forUpdate().clearMembers().update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).isEmpty();
+  }
+
+  @Test
+  public void furtherMembersCanBeAddedAfterClearingAll() throws Exception {
+    Account.Id memberId1 = new Account.Id(1000);
+    Account.Id memberId2 = new Account.Id(2000);
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
+
+    Account.Id memberId3 = new Account.Id(3000);
+    groupOperations.group(groupUuid).forUpdate().clearMembers().addMember(memberId3).update();
+
+    ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
+    assertThat(members).containsExactly(memberId3);
+  }
+
+  @Test
+  public void subgroupsCanBeAdded() throws Exception {
+    AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
+
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .addSubgroup(subgroupUuid1)
+        .addSubgroup(subgroupUuid2)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid1, subgroupUuid2);
+  }
+
+  @Test
+  public void subgroupsCanBeRemoved() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    groupOperations.group(groupUuid).forUpdate().removeSubgroup(subgroupUuid2).update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid1);
+  }
+
+  @Test
+  public void subgroupAdditionAndRemovalCanBeMixed() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .removeSubgroup(subgroupUuid1)
+        .addSubgroup(subgroupUuid3)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid2, subgroupUuid3);
+  }
+
+  @Test
+  public void subgroupsCanBeCleared() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    groupOperations.group(groupUuid).forUpdate().clearSubgroups().update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).isEmpty();
+  }
+
+  @Test
+  public void furtherSubgroupsCanBeAddedAfterClearingAll() throws Exception {
+    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID groupUuid =
+        groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
+
+    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    groupOperations
+        .group(groupUuid)
+        .forUpdate()
+        .clearSubgroups()
+        .addSubgroup(subgroupUuid3)
+        .update();
+
+    ImmutableSet<AccountGroup.UUID> subgroups = groupOperations.group(groupUuid).get().subgroups();
+    assertThat(subgroups).containsExactly(subgroupUuid3);
+  }
+
+  private GroupInput createArbitraryGroupInput() {
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name("verifiers-" + uniqueGroupNameIndex++);
+    return groupInput;
+  }
+
+  private GroupInfo getGroupFromServer(AccountGroup.UUID groupUuid) throws RestApiException {
+    return gApi.groups().id(groupUuid.get()).detail();
+  }
+
+  private AccountGroup.UUID createGroupInServer(GroupInput input) throws RestApiException {
+    GroupInfo group = gApi.groups().create(input).detail();
+    return new AccountGroup.UUID(group.id);
+  }
+
+  private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
+    return new Correspondence<AccountInfo, Account.Id>() {
+      @Override
+      public boolean compare(AccountInfo actualAccount, Account.Id expectedId) {
+        Account.Id accountId =
+            Optional.ofNullable(actualAccount)
+                .map(account -> account._accountId)
+                .map(Account.Id::new)
+                .orElse(null);
+        return Objects.equals(accountId, expectedId);
+      }
+
+      @Override
+      public String toString() {
+        return "has ID";
+      }
+    };
+  }
+
+  private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
+    return new Correspondence<GroupInfo, AccountGroup.UUID>() {
+      @Override
+      public boolean compare(GroupInfo actualGroup, AccountGroup.UUID expectedUuid) {
+        AccountGroup.UUID groupUuid =
+            Optional.ofNullable(actualGroup)
+                .map(group -> group.id)
+                .map(AccountGroup.UUID::new)
+                .orElse(null);
+        return Objects.equals(groupUuid, expectedUuid);
+      }
+
+      @Override
+      public String toString() {
+        return "has UUID";
+      }
+    };
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 29e9a0b..7be1827 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -49,6 +49,7 @@
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index c774fc5..8cd2753 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -426,7 +426,7 @@
   @Test
   public void approvalsPostSubmit() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
     update.putApproval("Verified", (short) 1);
@@ -461,7 +461,7 @@
   @Test
   public void approvalsDuringSubmit() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
     update.putApproval("Verified", (short) 1);
@@ -598,7 +598,7 @@
   @Test
   public void submitRecords() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
@@ -640,7 +640,7 @@
   @Test
   public void latestSubmitRecordsOnly() throws Exception {
     Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
     update.merge(
@@ -941,7 +941,7 @@
     // Finish off by merging the change.
     update = newUpdate(c, changeOwner);
     update.merge(
-        RequestId.forChange(c),
+        submissionId(c),
         ImmutableList.of(
             submitRecord(
                 "NOT_READY",
@@ -3141,4 +3141,8 @@
     update.commit();
     return tr.parseBody(commit);
   }
+
+  private RequestId submissionId(Change c) {
+    return new RequestId(c.getId().toString());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 43e2602..8daf67f 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -151,7 +151,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     update.merge(
         submissionId,
         ImmutableList.of(
@@ -220,7 +220,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = RequestId.forChange(c);
+    RequestId submissionId = submissionId(c);
     update.merge(
         submissionId, ImmutableList.of(submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
     update.commit();
@@ -424,4 +424,8 @@
     RevCommit commit = parseCommit(commitId);
     assertThat(commit.getFullMessage()).isEqualTo(expected);
   }
+
+  private RequestId submissionId(Change c) {
+    return new RequestId(c.getId().toString());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 42452df..8f4c90d 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -7,9 +7,11 @@
     resources = ["//prologtests:gerrit_common_test"],
     deps = [
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
new file mode 100644
index 0000000..27f4423
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -0,0 +1,96 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import org.junit.Test;
+
+public class IgnoreSelfApprovalRuleTest {
+  private static final Change.Id CHANGE_ID = new Change.Id(100);
+  private static final PatchSet.Id PS_ID = new PatchSet.Id(CHANGE_ID, 1);
+  private static final LabelType VERIFIED = makeLabel("Verified");
+  private static final Account.Id USER1 = makeAccount(100001);
+
+  @Test
+  public void filtersByLabel() {
+    LabelType codeReview = makeLabel("Code-Review");
+    PatchSetApproval approvalVerified = makeApproval(VERIFIED.getLabelId(), USER1, 2);
+    PatchSetApproval approvalCr = makeApproval(codeReview.getLabelId(), USER1, 2);
+
+    Collection<PatchSetApproval> filteredApprovals =
+        IgnoreSelfApprovalRule.filterApprovalsByLabel(
+            ImmutableList.of(approvalVerified, approvalCr), VERIFIED);
+
+    assertThat(filteredApprovals).containsExactly(approvalVerified);
+  }
+
+  @Test
+  public void filtersVotesFromUser() {
+    PatchSetApproval approvalM2 = makeApproval(VERIFIED.getLabelId(), USER1, -2);
+    PatchSetApproval approvalM1 = makeApproval(VERIFIED.getLabelId(), USER1, -1);
+
+    ImmutableList<PatchSetApproval> approvals =
+        ImmutableList.of(
+            approvalM2,
+            approvalM1,
+            makeApproval(VERIFIED.getLabelId(), USER1, 0),
+            makeApproval(VERIFIED.getLabelId(), USER1, +1),
+            makeApproval(VERIFIED.getLabelId(), USER1, +2));
+
+    Collection<PatchSetApproval> filteredApprovals =
+        IgnoreSelfApprovalRule.filterOutPositiveApprovalsOfUser(approvals, USER1);
+
+    assertThat(filteredApprovals).containsExactly(approvalM1, approvalM2);
+  }
+
+  private static LabelType makeLabel(String labelName) {
+    List<LabelValue> values = new ArrayList<>();
+    // The label text is irrelevant here, only the numerical value is used
+    values.add(new LabelValue((short) -2, "-2"));
+    values.add(new LabelValue((short) -1, "-1"));
+    values.add(new LabelValue((short) 0, "No vote."));
+    values.add(new LabelValue((short) 1, "+1"));
+    values.add(new LabelValue((short) 2, "+2"));
+    return new LabelType(labelName, values);
+  }
+
+  private static PatchSetApproval makeApproval(LabelId labelId, Account.Id accountId, int value) {
+    PatchSetApproval.Key key = makeKey(PS_ID, accountId, labelId);
+    return new PatchSetApproval(key, (short) value, Date.from(Instant.now()));
+  }
+
+  private static PatchSetApproval.Key makeKey(
+      PatchSet.Id psId, Account.Id accountId, LabelId labelId) {
+    return new PatchSetApproval.Key(psId, accountId, labelId);
+  }
+
+  private static Account.Id makeAccount(int account) {
+    return new Account.Id(account);
+  }
+}
diff --git a/plugins/BUILD b/plugins/BUILD
index 8b9f4de..a7622b2 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -38,6 +38,7 @@
     "//java/com/google/gerrit/metrics/dropwizard",
     "//java/com/google/gerrit/reviewdb:server",
     "//java/com/google/gerrit/server/audit",
+    "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/util/http",
     "//lib/commons:compress",
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 5608dec..00157ab 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -178,34 +178,20 @@
           <gr-account-label
               account="[[author]]"
               hide-avatar></gr-account-label>
-          <template is="dom-if" if="[[_successfulParse]]">
-            <template is="dom-if" if="[[_parsedVotes.length]]">voted</template>
-            <template is="dom-repeat" items="[[_parsedVotes]]" as="score">
-              <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
-                [[score.label]] [[score.value]]
-              </span>
-            </template>
-            [[_computeConversationalString(_parsedVotes, _parsedPatchNum, _parsedCommentCount)]]
+          <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">
-            <template is="dom-if" if="[[_successfulParse]]">
-              <div class="message hideOnOpen">[[_parsedChangeMessage]]</div>
-              <gr-formatted-text
-                  no-trailing-margin
-                  class="message hideOnCollapsed"
-                  content="[[_parsedChangeMessage]]"
-                  config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
-            </template>
-            <template is="dom-if" if="[[!_successfulParse]]">
-              <div class="message hideOnOpen">[[message.message]]</div>
-                <gr-formatted-text
-                    no-trailing-margin
-                    class="message hideOnCollapsed"
-                    content="[[message.message]]"
-                    config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
-            </template>
+            <div class="message hideOnOpen">[[message.message]]</div>
+            <gr-formatted-text
+                no-trailing-margin
+                class="message hideOnCollapsed"
+                content="[[message.message]]"
+                config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
             <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
               <gr-button link small on-tap="_handleReplyTap">Reply</gr-button>
             </div>
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 addd660..0590c73 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -17,8 +17,7 @@
 (function() {
   'use strict';
 
-  const PATCH_SET_PREFIX_PATTERN = /^Patch Set (\d)+:[ ]?/;
-  const COMMENTS_COUNT_PATTERN = /^\((\d+)( inline)? comments?\)$/;
+  const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+: /;
   const LABEL_TITLE_SCORE_PATTERN = /^([A-Za-z0-9-]+)([+-]\d+)$/;
 
   Polymer({
@@ -102,17 +101,10 @@
         type: Boolean,
         value: false,
       },
-
-      _parsedPatchNum: String,
-      _parsedCommentCount: String,
-      _parsedVotes: Array,
-      _parsedChangeMessage: String,
-      _successfulParse: Boolean,
     },
 
     observers: [
       '_updateExpandedClass(message.expanded)',
-      '_consumeMessage(message.message)',
     ],
 
     ready() {
@@ -192,6 +184,19 @@
       return event.type === 'REVIEWER_UPDATE';
     },
 
+    _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 []; }
+      const scoresRaw = line.split(patchSetPrefix)[1];
+      if (!scoresRaw) { return []; }
+      return scoresRaw.split(' ')
+          .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+          .filter(ms => ms && ms.length === 3)
+          .map(ms => ({label: ms[1], value: ms[2]}));
+    },
+
     _computeScoreClass(score, labelExtremes) {
       const classes = [];
       if (score.value > 0) {
@@ -255,73 +260,5 @@
       e.stopPropagation();
       this.set('message.expanded', !this.message.expanded);
     },
-
-    /**
-     * Attempts to consume a change message to create a shorter and more legible
-     * format. If the function encounters unexpected characters at any point, it
-     * sets the _successfulParse flag to false and terminates, causing the UI to
-     * fall back to displaying the entirety of the change message.
-     *
-     * A successful parse results in a one-liner that reads:
-     * `${AVATAR} voted ${VOTES} and left ${NUM} comment(s) on ${PATCHSET}`
-     *
-     * @param {string} text
-     */
-    _consumeMessage(text) {
-      this._parsedPatchNum = '';
-      this._parsedCommentCount = '';
-      this._parsedChangeMessage = '';
-      this._parsedVotes = [];
-      if (!text) {
-        // No message body means nothing to parse.
-        this._successfulParse = false;
-        return;
-      }
-      const lines = text.split('\n');
-      const messageLines = lines.shift().split(PATCH_SET_PREFIX_PATTERN);
-      if (!messageLines[1]) {
-        // Message is in an unexpected format.
-        this._successfulParse = false;
-        return;
-      }
-      this._parsedPatchNum = messageLines[1];
-      if (messageLines[2]) {
-        // Content after the colon is always vote information. If it is in the
-        // most up to date schema, parse it. Otherwise, cancel the parsing
-        // completely.
-        let match;
-        for (const score of messageLines[2].split(' ')) {
-          match = score.match(LABEL_TITLE_SCORE_PATTERN);
-          if (!match || match.length !== 3) {
-            this._successfulParse = false;
-            return;
-          }
-          this._parsedVotes.push({label: match[1], value: match[2]});
-        }
-      }
-      // Remove empty line.
-      lines.shift();
-      if (lines.length) {
-        const commentMatch = lines[0].match(COMMENTS_COUNT_PATTERN);
-        if (commentMatch) {
-          this._parsedCommentCount = commentMatch[1];
-          // Remove comment line and the following empty line.
-          lines.splice(0, 2);
-        }
-        this._parsedChangeMessage = lines.join('\n');
-      }
-      this._successfulParse = true;
-    },
-
-    _computeConversationalString(votes, patchNum, commentCount) {
-      let clause = ' on Patch Set ' + patchNum;
-      if (commentCount) {
-        let commentStr = ' comment';
-        if (parseInt(commentCount, 10) > 1) { commentStr += 's'; }
-        clause = ' left ' + commentCount + commentStr + clause;
-        if (votes.length) { clause = ' and' + clause; }
-      }
-      return clause;
-    },
   });
 })();
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 6bb4618..870f366 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
@@ -169,9 +169,7 @@
         author: {},
         expanded: false,
         message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Ready+1',
-        _revision_number: 1,
       };
-      element.comments = {};
       element.labelExtremes = {
         'Verified': {max: 1, min: -1},
         'Code-Review': {max: 2, min: -2},
@@ -201,145 +199,5 @@
       const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
       assert.equal(scoreChips.length, 0);
     });
-
-    suite('_consumeMessage', () => {
-      const assertConsumeFailed = str => {
-        element._consumeMessage(str);
-        assert.isFalse(element._successfulParse);
-      };
-
-      test('no message body', () => {
-        assertConsumeFailed('');
-      });
-
-      test('known old schema', () => {
-        const str = 'Patch Set 1: Looks good to me, approved; Verified';
-        assertConsumeFailed(str);
-      });
-
-      test('known old schema 2', () => {
-        const str = [
-          'Patch Set 2: Looks good to me',
-          '',
-          'Patch set 2 compiles, and runs as expected.',
-        ].join('\n');
-        assertConsumeFailed(str);
-      });
-
-      test('known old schema 3', () => {
-        const str = 'Patch Set 2: (1 inline comment)';
-        assertConsumeFailed(str);
-      });
-
-      test('known old schema 4', () => {
-        const str = [
-          'Patch Set 2: Looks good to me',
-          '',
-          '(1 inline comment)',
-        ].join('\n');
-        assertConsumeFailed(str);
-      });
-
-      test('just change message', () => {
-        const str = [
-          'Patch Set 2:',
-          '',
-          'I think you should reconsider this approach.',
-          'It really makes no sense.',
-        ].join('\n');
-
-        element._consumeMessage(str);
-
-        assert.isTrue(element._successfulParse);
-        assert.equal(element._parsedPatchNum, '2');
-        assert.equal(element._parsedCommentCount, '');
-        assert.deepEqual(element._parsedVotes, []);
-        assert.equal(element._parsedChangeMessage, [
-          'I think you should reconsider this approach.',
-          'It really makes no sense.',
-        ].join('\n'));
-      });
-
-      test('just votes', () => {
-        element._consumeMessage('Patch Set 2: Code-Review-Label+1 Verified+1');
-
-        assert.isTrue(element._successfulParse);
-        assert.equal(element._parsedPatchNum, '2');
-        assert.equal(element._parsedCommentCount, '');
-        assert.deepEqual(element._parsedVotes, [
-          {label: 'Code-Review-Label', value: '+1'},
-          {label: 'Verified', value: '+1'},
-        ]);
-        assert.equal(element._parsedChangeMessage, '');
-      });
-
-      test('just comments', () => {
-        const str = [
-          'Patch Set 2:',
-          '',
-          '(8 comments)',
-        ].join('\n');
-
-        element._consumeMessage(str);
-
-        assert.isTrue(element._successfulParse);
-        assert.equal(element._parsedPatchNum, '2');
-        assert.equal(element._parsedCommentCount, '8');
-        assert.deepEqual(element._parsedVotes, []);
-        assert.equal(element._parsedChangeMessage, '');
-      });
-
-      test('vote with comments and change message', () => {
-        const str = [
-          'Patch Set 2: Code-Review-Label+1 Verified+1',
-          '',
-          '(1 comment)',
-          '',
-          'LGTM',
-          '',
-          'Nice work, just one nit.',
-        ].join('\n');
-
-        element._consumeMessage(str);
-
-        assert.isTrue(element._successfulParse);
-        assert.equal(element._parsedPatchNum, '2');
-        assert.equal(element._parsedCommentCount, '1');
-        assert.deepEqual(element._parsedVotes, [
-          {label: 'Code-Review-Label', value: '+1'},
-          {label: 'Verified', value: '+1'},
-        ]);
-        assert.equal(element._parsedChangeMessage, [
-          'LGTM',
-          '',
-          'Nice work, just one nit.',
-        ].join('\n'));
-      });
-
-      test('vote with change message', () => {
-        const str = [
-          'Patch Set 2: Code-Review-Label+2 Verified+1',
-          '',
-          'LGTM',
-          '',
-          'Nice work.',
-        ].join('\n');
-
-        element._consumeMessage(str);
-
-        assert.isTrue(element._successfulParse);
-        assert.equal(element._parsedPatchNum, '2');
-        assert.equal(element._parsedCommentCount, '');
-        assert.deepEqual(element._parsedVotes, [
-          {label: 'Code-Review-Label', value: '+2'},
-          {label: 'Verified', value: '+1'},
-        ]);
-        assert.equal(element._parsedChangeMessage, [
-          'LGTM',
-          '',
-          'Nice work.',
-        ].join('\n'));
-      });
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 84c1803..af10b8371 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -1498,11 +1498,28 @@
      */
     getRepos(filter, reposPerPage, opt_offset) {
       const defaultFilter = 'state:active OR state:read-only';
+      const namePartDelimiters = /[@.\-\s\/_]/g;
       const offset = opt_offset || 0;
 
+      if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
+        // The query language specifies hyphens as operators. Split the string
+        // by hyphens and 'AND' the parts together as 'inname:' queries.
+        // If the filter includes a semicolon, the user is using a more complex
+        // query so we trust them and don't do any magic under the hood.
+        const originalFilter = filter;
+        filter = '';
+        originalFilter.split(namePartDelimiters).forEach(part => {
+          if (part) {
+            filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
+          }
+        });
+      }
+      // Check if filter is now empty which could be either because the user did
+      // not provide it or because the user provided only a split character.
       if (!filter) {
         filter = defaultFilter;
       }
+
       filter = filter.trim();
       const encodedFilter = encodeURIComponent(filter);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index b7466ef..d9656e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -951,16 +951,46 @@
             '/projects/?n=26&S=25&query=test');
       });
 
-      test('with filter', () => {
-        element.getRepos('test/test/test', 25);
+      test('with blank', () => {
+        element.getRepos('test/test', 25);
         assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=test%2Ftest%2Ftest');
+            '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
       });
 
-      test('with regex filter', () => {
-        element.getRepos('^test.*', 25);
+      test('with hyphen', () => {
+        element.getRepos('foo-bar', 25);
         assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=%5Etest.*');
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with leading hyphen', () => {
+        element.getRepos('-bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Abar');
+      });
+
+      test('with trailing hyphen', () => {
+        element.getRepos('foo-bar-', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with underscore', () => {
+        element.getRepos('foo_bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('with underscore', () => {
+        element.getRepos('foo_bar', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+      });
+
+      test('hyphen only', () => {
+        element.getRepos('-', 25);
+        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+            `/projects/?n=26&S=0&query=${defaultQuery}`);
       });
     });