Merge branch 'stable-3.9'

* stable-3.9:
  PolyGerrit: use project~changeNum when fetching suggested accounts
  Set version to 3.9.1-SNAPSHOT
  Set version to 3.9.0
  Make sure to discard a draft whenever we're saving.
  Position tooltip in front of the dialog.
  Assume relative links without / prefix are absolute
  Set version to 3.8.4-SNAPSHOT
  Set version to 3.8.3
  Set version to 3.7.7-SNAPSHOT
  Set version to 3.7.6
  Set version to 3.6.9-SNAPSHOT
  Set version to 3.6.8
  Update jgit to 82e277c81339
  Fire `ref-updated` stream events on online edit refs update
  Fix documentation attributes bug
  Introduce Documentation:searchfree_safe with local-only fonts

Release-Notes: skip
Change-Id: Iead90cc3dd6c74229332a3f94c209349e688b5d5
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 19a19dd..b37ee73 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -264,6 +264,7 @@
 `-2..+2`, as the user's membership of `Foo Leads` effectively grant
 them access to the entire reference space, thanks to the wildcard.
 
+[[exclusive]]
 Gerrit also supports exclusive reference-level access control.
 
 It is possible to configure Gerrit to grant an exclusive ref level
@@ -443,13 +444,13 @@
 
 
 [[category_create]]
-=== Create Reference
+=== Create (aka Create Reference)
 
-The create reference category controls whether it is possible to
-create new references, branches or tags.  This implies that the
-reference must not already exist, it's not a destructive permission
-in that you can't overwrite or remove any previously existing
-references (and also discard any commits in the process).
+The create category controls whether it is possible to create new
+references, branches or tags.  This implies that the reference must not
+already exist, it's not a destructive permission in that you can't
+overwrite or remove any previously existing references (and also
+discard any commits in the process).
 
 It's probably most common to either permit the creation of a single
 branch in many gits (by granting permission on a parent project), or
@@ -484,9 +485,9 @@
 `${username}` are still reachable by the users.
 
 [[category_delete]]
-=== Delete Reference
+=== Delete (aka Delete Reference)
 
-The delete reference category controls whether it is possible to delete
+The delete category controls whether it is possible to delete
 references, branches or tags. It doesn't allow any other update of
 references.
 
@@ -1618,6 +1619,7 @@
 
 Access to refs can be blocked, allowed or denied.
 
+[[block-rule]]
 ==== BLOCK
 
 For blocking access, all rules marked BLOCK are tested, and if one
@@ -1643,6 +1645,7 @@
 permissions when they are processed in the ALLOW/DENY processing, as
 described in the next subsection.
 
+[[allow-rule]]
 ==== ALLOW
 
 For allowing access, all ALLOW/DENY rules that might apply to a ref
@@ -1656,6 +1659,7 @@
 This ordering lets project owners apply permissions specific to their
 project, overwriting the site defaults specified in All-Projects.
 
+[[deny-rule]]
 ==== DENY
 
 DENY is processed together with ALLOW.
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 187cd0f..45004b0 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -1,34 +1,239 @@
-= Gerrit Code Review - Project Configuration File Format
+= Gerrit Code Review - Project Configuration
 
-This page explains the storage format of Gerrit's project configuration
-and access control models.
+Every project has a configuration that defines access rights and controls
+project-specific behavior.
 
-The web UI access control panel is a front end for human-readable
-configuration files under the +refs/meta/config+ namespace in the
-affected project.  Direct manipulation of these files is mainly
-relevant in an automation scenario of the access controls.
-
+The project configuration is stored inside the git repository inside the
+`refs/meta/config` branch.
 
 [[refs-meta-config]]
-== The +refs/meta/config+ namespace
+== The `refs/meta/config` branch
 
-The namespace contains three different files that play different
-roles in the permission model.  With read permission to that reference,
-it is possible to fetch the +refs/meta/config+ reference to a local
-repository.  A nice side effect is that you can also upload changes
-to project permissions and review them just like with regular code
-changes. The preview changes option is also provided on the UI. Please note
-that you will have to configure push rights for the +refs/meta/config+ name
-space if you'd like to use the possibility to automate permission updates.
+The `refs/meta/config` branch contains configuration files. It is disconnected
+from the normal branches under `refs/heads/` that contain the source code.
 
-== Property inheritance
+The files inside the `refs/meta/config` branch are versioned just like any
+other file in the repository. This means from the git history of the
+`refs/meta/config` branch it can be seen how the configuration changed
+over time and which configuration was active when.
 
-If a property is set to INHERIT, then the value from the parent project is
-used. If the property is not set in any parent project, the default value is
-FALSE.
+[[configuration-files]]
+== Configuration files
+
+The project configuration is stored in the following files (these files are
+stored inside the `refs/meta/config` branch, see
+link:#refs-meta-config[above]):
+
+* link:#file-project_config[project.config]:
+  Contains the link to the parent project, the project description, access
+  rights and options to control project-specific behavior.
+* link:#file-groups[groups]:
+  Resolves group names that are mentioned in `project.config` to group UUIDs.
+* [DEPRECATED] link:#file-rules_pl[rules.pl]:
+  Contains Prolog rules that control when a change becomes ready to submit.
+  Note, Prolog rules are deprecated and have been replaced by
+  link:config-submit-requirements.html[submit requirements].
+
+In addition, there can be configuration files from Gerrit plugins, usually they
+are named `<PLUGIN-NAME>.config`.
+
+[[update]]
+== Updating the project configuration
+
+There are several possibilities to update the project configuration:
+
+[[update-through-web-ui]]
+* Through the Gerrit web UI:
++
+[[update-through-general-screen]]
+--
+* On the project's `General` screen:
++
+The `General` screen can be found under:
+`BROWSE` > `Repositories` > `<project-name>` > `General`
++
+In the `Configurations` section this screen allows to edit the project
+description and change repository options.
++
+Note, this screen only supports changing the most important repository options,
+but it doesn't expose all options that exist.
++
+All changes are directly applied (without code review).
+--
++
+[[update-through-access-screen]]
+--
+* On the project's `Access` screen:
++
+The `Access` screen can be found under:
+`BROWSE` > `Repositories` > `<project-name>` > `Access`
++
+This screen allows to edit the project's access rights.
++
+Updating access rights via the `Access` screen updates the
+link:#file-groups[groups] file automatically.
++
+Modifications can either be applied directly (`SAVE` button) or saved for
+review (`SAVE FOR REVIEW` button).
++
+Saving access modification for review (`SAVE FOR REVIEW` button) creates an
+open change for the `refs/meta/config` branch where the modifications can be
+reviewed by a project owner. The modifications become effective only after a
+project owner submits the change.
+--
++
+[[update-via-online-editing]]
+--
+** Via online editing:
++
+The project's `Commands` screen, that can be found under `BROWSE` >
+`Repositories` > `<project-name>` > `Commands`, offers an `EDIT REPO CONFIG`
+command that allows to edit the `project.config` file directly in the Gerrit
+web UI.
++
+The `EDIT REPO CONFIG` command creates a new work-in-progress change with a
+change edit that contains the `project.config` file. Clicking on the command
+opens an online editor for the `project.config` file, allowing the user to make
+modifications to it.
++
+Modifications need to be saved and published ("saving" saves the modifications
+in the change edit, "publishing" publishes the change edit to make it visible
+to other users). While the change edit is not published yet, it's possible to
+add further files to it (e.g. the link:#file-groups[groups] file if it needs to
+be modified, in contrast to making access rights changes through the `Access`
+screen the `groups` file is not automatically updated). For further details how
+to work with change edits see the link:user-inline-edit.html[inline edit user
+guide]. After publishing the change edit, the change should be set to ready by
+clicking on `START REVIEW` > `SEND AND START REVIEW`. At this time you also
+want to add a project owner as a reviewer so that they can review and approve
+the change.
+--
+
+[[update-by-git-push]]
+* By pushing updates via Git:
++
+Since the configuration files are stored in a git branch, it's possible to
+update them via normal git operations:
++
+--
+1. Clone the repository if you don't have it available yet. The clone command
+   can be found in the `Download` section of project's `General` screen
+   (`BROWSE` > `Repositories` > `<project-name>` > `General`).
+2. Fetch and checkout the `refs/meta/config branch`, e.g. by `git fetch origin
+   refs/meta/config && git checkout FETCH_HEAD`.
+3. Edit the `project.config` file or other configuration files and commit the
+   changes, e.g. by `git commit --all`. Note, since the `project.config` file
+   uses the format of a git config file you can also edit it via the
+   `git config` command (e.g. to set a project description do: `git config -f
+   project.config project.description "My project description"`).
+4. Push the newly created commit, either to update the `refs/meta/config`
+   branch directly without code-review (e.g. `git push origin
+   HEAD:refs/meta/config`), or for review (e.g. `git push origin
+   HEAD:refs/for/refs/meta/config`).
+--
++
+[NOTE]
+Updates to access right may require changes in the link:#file-groups[groups]
+file. In contrast to making access rights changes through the `Access` screen
+the `groups` file is not automatically updated.
+
+[[update-through-rest-api]]
+* Through the Gerrit REST API:
++
+Gerrit offers several REST endpoints to modify the project configuration:
++
+** link:rest-api-projects.html#set-config[Set Config] REST endpoint:
+   Allows to edit the project description and change repository options.
+** link:rest-api-projects.html#set-access[Add, Update and Delete Access Rights
+   for Project] REST endpoint:
+   Allows to edit the access rights of the project.
+** link:rest-api-projects.html#create-access-change[Create Access Rights Change
+   for review] REST endpoint:
+   Allows to create a change with access right modifications that can be
+   reviewed and submitted by a project owner.
+
+[[required-permissions]]
+== Required permissions
+
+Depending on how the project configuration is changed different access rights
+are required:
+
+* Direct updates through the web UI (link:#update-through-general-screen[
+  updates through the `General` screen] and link:#update-through-access-screen[
+  direct updates of access rights through the `Access` screen via the `SAVE`
+  button]) and direct updates through the REST API (via the
+  link:rest-api-projects.html#set-config[Set Config] REST endpoint or the
+  link:rest-api-projects.html#set-access[Add, Update and Delete Access Rights
+  for Project] REST endpoint) require the user to be a project owner (have the
+  link:access-control.html#category_owner[Owner] access right assigned on
+  `refs/*` or have the link:access-control.html#capability_administrateServer[
+  Administrate Server] global capability assigned on the `All-Projects` root
+  project).
+* link:#update-by-git-push[Direct updates through `git push`] require the
+  user to have the link:access-control.html#category_push[Push] access right
+  assigned on `refs/meta/config` and be a project owner (have the
+  link:access-control.html#category_owner[Owner] access right assigned on
+  `refs/*` or have the link:access-control.html#capability_administrateServer[
+  Administrate Server] global capability assigned on the `All-Projects` root
+  project).
+* Creating changes for updates through the web UI
+  (link:#update-through-access-screen[proposing updates of access rights
+  through the `Access` screen via the `SAVE FOR REVIEW` button] and
+  link:#update-via-online-editing[proposing updates via the `EDIT REPO CONFIG`
+  command]), creating changes for updates through the REST API (via the
+  link:rest-api-projects.html#create-access-change[Create Access Rights Change
+  for review] REST endpoint) and link:#update-by-git-push[pushing changes for
+  review] require the user to be able to see the `refs/meta/config` branch
+  (have the link:access-control.html#category_read[Read] access right assigned
+  on `refs/meta/config`) and be allowed to create changes for it (have the
+  link:access-control.html#category_push[Push] access right assigned on
+  `refs/for/refs/meta/config` or be a project owner or be an administrator).
+
+[[comments]]
+=== Comments in project configuration files
+
+In principle it's possible to have comments in the project configuration files
+(lines starting with '#'), however if any Gerrit API is used that lead to
+modifications in the configuration files the comments may be dropped. This is
+because when Gerrit parses the configuration files and writes them back with
+updates, comments are not preserved.
+
+[TIP]
+When updating the project configuration use the commit message to record the
+reason for the settings so that this information is preserved in the git
+history. For example, this allows using `git blame` to inspect why
+configuration values have been set.
+
+[[inheritance]]
+== Inheritance
+
+Projects in Gerrit are organized hierarchically in a tree with the
+`All-Projects` project as the root project. The parent project is defined in
+the link:#access.inheritFrom[access section] of the `project.config` file.
+
+Projects inherit access rights and options from their parent project, but not
+all options are inheritable. See the description of the options in the
+link:#file-project_config[project.config] file to learn whether they are
+inherited or not.
+
+Options with boolean values support a special `INHERIT` value to make them
+inherit the value that is set in the parent project.
+
+Some settings can be enforced for child projects (or if set on the
+`All-Projects` root project for all projects), e.g. access right restrictions
+via link:access-control#block[BLOCK rules] or
+link:config-submit-requirements.html#submit_requirement_can_override_in_child_projects[
+non-overridable] submit requirements.
+
+[NOTE]
+Project owners can be allowed to change the parent of projects that they own
+(see config-gerrit.html#receive.allowProjectOwnersToChangeParent[
+receive.allowProjectOwnersToChangeParent] setting which is `false` by default).
+In this case project owners may escape the settings that are enforced by their
+parent project by choosing a different parent project.
 
 [[file-project_config]]
-== The file +project.config+
+== The file `project.config`
 
 The +project.config+ file contains the link between groups and their
 permitted actions on reference patterns in this project and any projects
@@ -647,13 +852,58 @@
 [[access-subsection]]
 ==== Access subsection
 
-+access+ subsections for references connect access rights to groups. Each group
-listed must exist in the link:#file-groups[+groups+ file].
+`access` subsections define access rules for a ref or a ref namespace. The ref
+or ref namespace is specified as the subsection name and can be a concrete ref
+(e.g. `refs/heads/master`), a ref pattern (last path segment is '\*', e.g.
+`refs/heads/*`) or a regular expression (must start with '^', e.g.
+`^refs/heads/rel-.*`).
 
-Please refer to the
-link:access-control.html#access_categories[Access Categories]
-documentation for a full list of available access rights.
+[NOTE]
+For ref patterns '\*' can only appear as the last path segment. If a '*' is
+required in any other place the ref namespace must be specified as a regular
+expression (must start with '^', '\*' must follow what's being matched, e.g.
+".*" to match any string).
 
+The `access` subsections contain access rules that apply to the ref or ref
+namespace of the `access` subsections. The format of the access rules is: +
+`<accessCategoryId> = (block|deny)? <range>? group <group-name>`
+
+* `<accessCategoryId>`: ID of the link:access-control.html#access_categories[
+  access category] for which the access rule should be defined. The ID of the
+  access category is the name of the access category in lowerCamelCase (e.g.
+  `createTag`), except for label permissions where it is `label-<label-Name>`
+  (e.g. `label-Code-Review`).
+* `(block|deny)?`: `block` defines a link:access-control.html#block-rule[BLOCK]
+  rule, `deny` defines a link:access-control.html#deny-rule[DENY] rule, if
+  neither `block` or `deny` is specified an link:access-control.html#allow-rule[
+  ALLOW] rule is defined.
+* `<range>?`: Only set for label permission. The voting range in the format
+  `<min-vote>..<max-vote>` (e.g. `-1..+1`).
+* `group <group-name>`: The (local) name of the group to which the access rule
+  should apply (e.g. `group Foo Bar`). The (local) group name must exist in the
+  link:#file-groups[groups] file, so that Gerrit can resolve it to the group
+  UUID.
+
+To make access rules link:access-control.html#exclusive[exclusive] they need to
+be included into the value of the `exclusiveGroupPermissions` key: +
+`exclusiveGroupPermissions = <space-separated-list-of-access-category-ids>`
+
+.Example access subsections
+----
+  [access "refs/heads/*"]
+    create = group Administrators
+    delete = group Administrators
+    deleteChanges = group Administrators
+    label-Code-Review = -2..+2 group Maintainers
+    label-Code-Review = -1..+1 group Registered Users
+    label-Verified = -1..+1 group Registered Users
+    push = block Registered Users
+    submit = group Maintainers
+    exclusiveGroupPermissions = deleteChanges submit
+  [access "^refs/tags/rel-.*"]
+    createTag = group Maintainers
+    createSignedTag = group Maintainers
+----
 
 [[mimetype-section]]
 === MIME Types section
@@ -753,13 +1003,25 @@
 default value is `FALSE`.
 
 [[file-groups]]
-== The file +groups+
+== The file `groups`
 
-Each group in this list is linked with its UUID so that renaming of
-groups is possible without having to rewrite every +groups+ file
-in every repository where it's used.
+The `groups` file resolves group names that are mentioned in
+link:#access-subsection[access subsections] of the link:#file-project_config[
+project.config] file to group UUIDs.
 
-This is what the default groups file for +All-Projects.git+ looks like:
+In the access subsections access rights are assigned to group names. These
+group names are local to the project configuration and do not need to match
+with the actual group names. To enable Gerrit to resolve the local group names
+they must be mapped to group UUIDs in the `groups` file.
+
+The access sections use local group names, rather than requiring the actual
+group names, to allow renaming groups in Gerrit without having to rewrite every
+`project.config` file using the group.
+
+The content of the `groups` file is a simple table of group UUID to group name,
+separated by a tab.
+
+This is how the default `groups` file for `All-Projects` project looks like:
 
 ----
 # UUID                                         Group Name
@@ -771,29 +1033,37 @@
 global:Registered-Users                        Registered Users
 ----
 
-This file can't be written to by the +git config+ command.
+Since the `groups` file has a custom format it can't be edited using the
+`git config` command.
 
-In order to reference a group in +project.config+, it must be listed in
-the +groups+ file.  When editing permissions through the web UI this
-file is maintained automatically, but when pushing updates to
-+refs/meta/config+ this must be dealt with by hand.  Gerrit will refuse
-+project.config+ files that refer to groups not listed in +groups+.
+Whenever access rights in the `project.config` file are assigned to new groups
+mapping entries for the new groups must be added to the `groups` file. The
+modifications to the `groups` file must be included in the same commit that
+updates the `project.config` file. Pushing updates to `project.config` files
+that refer to groups not listed in the `groups` file are rejected by Gerrit.
 
-The UUID of a group can be found on the General tab of the group's page
-in the web UI or via the +-v+ option to
-link:cmd-ls-groups.html[the +ls-groups+ SSH command].
+When link:#update-through-access-screen[editing access rights through the web
+UI] the `groups` file is automatically updated by Gerrit.
 
+The UUID of a group can be found on the group screen (`BROWSE` > `Groups` >
+`<group-name>` ). Alternatively the group can be looked up via the
+link:rest-api-groups.html#get-group[Get Group] REST endpoint (note that the
+`group-id` in the URL can be the group name). The group UUID is contained as
+`id` field in the return link:rest-api-groups.html#group-info[GroupInfo] JSON.
 
 [[file-rules_pl]]
-== The file +rules.pl+
+== The file `rules.pl`
 
-The +rules.pl+ files allows you to replace or amend the default Prolog
-rules that control e.g. what conditions need to be fulfilled for a
-change to be submittable.  This file content should be
-interpretable by the 'Prolog Cafe' interpreter.
+The `rules.pl` file allows to replace or amend the default Prolog rules that
+control what conditions need to be fulfilled for a change to be submittable.
+This file should be interpretable by the 'Prolog Cafe' interpreter.
 
-You can read more about the +rules.pl+ file and the prolog rules on
-link:prolog-cookbook.html[the Prolog cookbook page].
+You can read more about prolog rules on the link:prolog-cookbook.html[Prolog
+cookbook] page.
+
+[NOTE]
+Prolog rules are deprecated and have been replaced by
+link:config-submit-requirements.html[submit requirements].
 
 GERRIT
 ------
diff --git a/WORKSPACE b/WORKSPACE
index 1b70606..d60916e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -52,10 +52,10 @@
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "3bd7828aa5af4b13b99c191e8b1e884ebfa9ad371b0ce264605d347f135d2568",
-    strip_prefix = "protobuf-3.19.4",
+    sha256 = "75be42bd736f4df6d702a0e4e4d30de9ee40eac024c4b845d17ae4cc831fe4ae",
+    strip_prefix = "protobuf-21.7",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.19.4.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v21.7.tar.gz",
     ],
 )
 
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index e1e143c..baac18f 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -216,7 +216,7 @@
   public int lineNbr;
 
   public Identity author;
-  protected Identity realAuthor;
+  public Identity realAuthor;
 
   // TODO(issue-15525): Migrate this field from Timestamp to Instant
   public Timestamp writtenOn;
diff --git a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
new file mode 100644
index 0000000..d14aa97
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2023 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.entities.converter;
+
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.HumanComment.InFilePosition;
+import com.google.gerrit.proto.Entities.HumanComment.InFilePosition.Side;
+import com.google.protobuf.Parser;
+import java.time.Instant;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Proto converter between {@link HumanComment} and {@link Entities.HumanComment}. */
+@Immutable
+public enum HumanCommentProtoConverter
+    implements ProtoConverter<Entities.HumanComment, HumanComment> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.ObjectId, ObjectId> objectIdConverter =
+      ObjectIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.HumanComment toProto(HumanComment val) {
+
+    Entities.HumanComment.Builder res =
+        Entities.HumanComment.newBuilder()
+            .setPatchsetId(val.key.patchSetId)
+            .setAccountId(accountIdConverter.toProto(val.author.getId()))
+            .setCommentUuid(val.key.uuid)
+            .setCommentText(val.message)
+            .setUnresolved(val.unresolved)
+            .setWrittenOnMillis(val.writtenOn.toInstant().toEpochMilli())
+            .setServerId(val.serverId);
+    if (!val.key.filename.equals(PATCHSET_LEVEL)) {
+      InFilePosition.Builder inFilePos =
+          InFilePosition.newBuilder()
+              .setFilePath(val.key.filename)
+              .setSide(val.side <= 0 ? Side.PARENT : Side.REVISION);
+      if (val.range != null) {
+        inFilePos.setPositionRange(
+            InFilePosition.Range.newBuilder()
+                .setStartLine(val.range.startLine)
+                .setStartChar(val.range.startChar)
+                .setEndLine(val.range.endLine)
+                .setEndChar(val.range.endChar));
+      }
+      if (val.lineNbr != 0) {
+        inFilePos.setLineNumber(val.lineNbr);
+      }
+      res.setInFilePosition(inFilePos);
+    }
+
+    if (val.parentUuid != null) {
+      res.setParentCommentUuid(val.parentUuid);
+    }
+    if (val.tag != null) {
+      res.setTag(val.tag);
+    }
+    if (val.realAuthor != null) {
+      res.setRealAuthor(accountIdConverter.toProto(val.realAuthor.getId()));
+    }
+    if (val.getCommitId() != null) {
+      res.setDestCommitId(objectIdConverter.toProto(val.getCommitId()));
+    }
+
+    return res.build();
+  }
+
+  @Override
+  public HumanComment fromProto(Entities.HumanComment proto) {
+    Optional<InFilePosition> optInFilePosition =
+        proto.hasInFilePosition() ? Optional.of(proto.getInFilePosition()) : Optional.empty();
+    Comment.Key key =
+        new Comment.Key(
+            proto.getCommentUuid(),
+            optInFilePosition.isPresent() ? optInFilePosition.get().getFilePath() : PATCHSET_LEVEL,
+            proto.getPatchsetId());
+    HumanComment res =
+        new HumanComment(
+            key,
+            accountIdConverter.fromProto(proto.getAccountId()),
+            Instant.ofEpochMilli(proto.getWrittenOnMillis()),
+            optInFilePosition.isPresent()
+                ? (short) optInFilePosition.get().getSide().getNumber()
+                : Side.REVISION_VALUE,
+            proto.getCommentText(),
+            proto.getServerId(),
+            proto.getUnresolved());
+
+    res.parentUuid = proto.hasParentCommentUuid() ? proto.getParentCommentUuid() : null;
+    res.tag = proto.hasTag() ? proto.getTag() : null;
+    if (proto.hasRealAuthor()) {
+      res.realAuthor = new Comment.Identity(accountIdConverter.fromProto(proto.getRealAuthor()));
+    }
+    if (proto.hasDestCommitId()) {
+      res.setCommitId(objectIdConverter.fromProto(proto.getDestCommitId()));
+    }
+
+    optInFilePosition.ifPresent(
+        inFilePosition -> {
+          if (inFilePosition.hasPositionRange()) {
+            var range = inFilePosition.getPositionRange();
+            res.range =
+                new Range(
+                    range.getStartLine(),
+                    range.getStartChar(),
+                    range.getEndLine(),
+                    range.getEndChar());
+          }
+          if (inFilePosition.hasLineNumber()) {
+            res.lineNbr = inFilePosition.getLineNumber();
+          }
+        });
+    return res;
+  }
+
+  @Override
+  public Parser<Entities.HumanComment> getParser() {
+    return Entities.HumanComment.parser();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 054a6dc9..0187c16 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -114,6 +114,7 @@
   private boolean matchAuthorToCommitterDate = false;
   private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private String mergeStrategy;
+  private boolean verifyNeedsRebase = true;
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -275,6 +276,11 @@
     return this;
   }
 
+  public RebaseChangeOp setVerifyNeedsRebase(boolean verifyNeedsRebase) {
+    this.verifyNeedsRebase = verifyNeedsRebase;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
       throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
@@ -441,7 +447,7 @@
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
-    if (base.equals(parentCommit)) {
+    if (verifyNeedsRebase && base.equals(parentCommit)) {
       throw new ResourceConflictException("Change is already up to date.");
     }
 
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index d27b13ad..c783e40 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -3024,7 +3024,11 @@
           reject(inputCommand, "change " + ontoChange + " closed");
           return false;
         } else if (revisions.containsKey(newCommit)) {
-          reject(inputCommand, "commit already exists (in the change)");
+          reject(
+              inputCommand,
+              String.format(
+                  "commit %s already exists in change %s",
+                  newCommit.name().substring(0, 10), change.getId()));
           return false;
         }
 
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 87de810..cd17fe7 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
-import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
@@ -41,11 +40,8 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
 public class RebaseSubmitStrategy extends SubmitStrategy {
@@ -145,90 +141,61 @@
     public void updateRepoImpl(RepoContext ctx)
         throws InvalidChangeOperationException, RestApiException, IOException,
             PermissionBackendException {
-      if (args.mergeUtil.canFastForward(
-          args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
-        if (!rebaseAlways) {
-          if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
-              && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
-            toMerge.setStatusCode(EMPTY_COMMIT);
-            return;
-          }
+      if (!rebaseAlways
+          && args.mergeUtil.canFastForward(
+              args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
+        if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+            && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
+          toMerge.setStatusCode(EMPTY_COMMIT);
+          return;
+        }
 
-          args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
-          toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-          acceptMergeTip(args.mergeTip);
-          return;
-        }
-        // RebaseAlways means we modify commit message.
-        args.rw.parseBody(toMerge);
-        newPatchSetId =
-            ChangeUtil.nextPatchSetIdFromChangeRefs(
-                ctx.getRepoView().getRefs(getId().toRefPrefix()).keySet(),
-                toMerge.change().currentPatchSetId());
-        RevCommit mergeTip = args.mergeTip.getCurrentTip();
-        args.rw.parseBody(mergeTip);
-        String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-        PersonIdent committer =
-            Optional.ofNullable(toMerge.getCommitterIdent())
-                .map(ident -> ctx.newCommitterIdent(ident.getEmailAddress(), args.caller))
-                .orElseGet(() -> ctx.newCommitterIdent(args.caller));
-        try {
-          newCommit =
-              args.mergeUtil.createCherryPickFromCommit(
-                  ctx.getInserter(),
-                  ctx.getRepoView().getConfig(),
-                  args.mergeTip.getCurrentTip(),
-                  toMerge,
-                  committer,
-                  cherryPickCmtMsg,
-                  args.rw,
-                  0,
-                  true,
-                  false);
-        } catch (MergeConflictException mce) {
-          // Unlike in Cherry-pick case, this should never happen.
-          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-          throw new IllegalStateException(
-              "MergeConflictException on message edit must not happen", mce);
-        } catch (MergeIdenticalTreeException mie) {
-          // this should not happen
-          toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
-          return;
-        }
-        ctx.addRefUpdate(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName());
-      } else {
-        // Stale read of patch set is ok; see comments in RebaseChangeOp.
-        PatchSet origPs = args.psUtil.get(toMerge.getNotes(), toMerge.getPatchsetId());
-        rebaseOp =
-            args.rebaseFactory
-                .create(toMerge.notes(), origPs, args.mergeTip.getCurrentTip())
-                .setFireRevisionCreated(false)
-                // Bypass approval copier since SubmitStrategyOp copy all approvals
-                // later anyway.
-                .setValidate(false)
-                .setCheckAddPatchSetPermission(false)
-                // RebaseAlways should set always modify commit message like
-                // Cherry-Pick strategy.
-                .setDetailedCommitMessage(rebaseAlways)
-                // Do not post message after inserting new patchset because there
-                // will be one about change being merged already.
-                .setPostMessage(false)
-                .setSendEmail(false)
-                .setMatchAuthorToCommitterDate(
-                    args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE))
-                // The votes are automatically copied and they don't count as copied votes. See
-                // method's javadoc.
-                .setStoreCopiedVotes(/* storeCopiedVotes = */ false);
-        try {
-          rebaseOp.updateRepo(ctx);
-        } catch (MergeConflictException | NoSuchChangeException e) {
-          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-          throw new IntegrationConflictException(
-              "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
-        }
-        newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
-        newPatchSetId = rebaseOp.getPatchSetId();
+        args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
+        toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
+        acceptMergeTip(args.mergeTip);
+        return;
       }
+
+      args.rw.parseBody(toMerge);
+      newPatchSetId =
+          ChangeUtil.nextPatchSetIdFromChangeRefs(
+              ctx.getRepoView().getRefs(getId().toRefPrefix()).keySet(),
+              toMerge.change().currentPatchSetId());
+      // Stale read of patch set is ok; see comments in RebaseChangeOp.
+      PatchSet origPs = args.psUtil.get(toMerge.getNotes(), toMerge.getPatchsetId());
+      rebaseOp =
+          args.rebaseFactory
+              .create(toMerge.notes(), origPs, args.mergeTip.getCurrentTip())
+              .setFireRevisionCreated(false)
+              // Bypass approval copier since SubmitStrategyOp copy all approvals
+              // later anyway.
+              .setValidate(false)
+              .setCheckAddPatchSetPermission(false)
+              // RebaseAlways should set always modify commit message like
+              // Cherry-Pick strategy.
+              .setDetailedCommitMessage(rebaseAlways)
+              // Do not post message after inserting new patchset because there
+              // will be one about change being merged already.
+              .setPostMessage(false)
+              .setSendEmail(false)
+              .setMatchAuthorToCommitterDate(
+                  args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE))
+              // The votes are automatically copied and they don't count as copied votes. See
+              // method's javadoc.
+              .setStoreCopiedVotes(/* storeCopiedVotes = */ false)
+              .setVerifyNeedsRebase(/* verifyNeedsRebase= */ !rebaseAlways);
+
+      try {
+        rebaseOp.updateRepo(ctx);
+      } catch (MergeConflictException | NoSuchChangeException e) {
+        toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
+        throw new IntegrationConflictException(
+            "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
+      }
+
+      newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
+      newPatchSetId = rebaseOp.getPatchSetId();
+
       if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
           && newCommit.getTree().equals(newCommit.getParent(0).getTree())) {
         toMerge.setStatusCode(EMPTY_COMMIT);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 7628978..c37be7e 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -237,6 +237,8 @@
 
   @Inject protected ProjectOperations projectOperations;
   @Inject protected Emails emails;
+  @Inject protected ExtensionRegistry extensionRegistry;
+  @Inject protected RequestScopeOperations requestScopeOperations;
 
   @Inject protected GroupOperations groupOperations;
 
@@ -248,12 +250,10 @@
   @Inject private Provider<InternalAccountQuery> accountQueryProvider;
   @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
   @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
-  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private RetryHelper.Metrics retryMetrics;
   @Inject private Sequences seq;
   @Inject private StalenessChecker stalenessChecker;
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
-  @Inject private ExtensionRegistry extensionRegistry;
   @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
   @Inject private ExternalIdFactoryNoteDbImpl externalIdFactory;
@@ -549,7 +549,7 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      AccountInfo info = gApi.accounts().id("admin").get();
+      AccountInfo info = gApi.accounts().id(admin.id().get()).get();
       assertThat(info.name).isEqualTo("Administrator");
       assertThat(info.email).isEqualTo("admin@example.com");
       assertThat(info.username).isEqualTo("admin");
@@ -562,7 +562,7 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      AccountInfo info = gApi.accounts().id("admin").get();
+      AccountInfo info = gApi.accounts().id(admin.id().get()).get();
       AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
       assertThat(info.name).isEqualTo(infoByIntId.name);
       accountIndexedCounter.assertNoReindex();
@@ -577,7 +577,7 @@
       AccountInfo info = gApi.accounts().self().get();
       assertUser(info, admin);
 
-      info = gApi.accounts().id("self").get();
+      info = gApi.accounts().self().get();
       assertUser(info, admin);
       accountIndexedCounter.assertNoReindex();
     }
@@ -588,9 +588,9 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      int id = gApi.accounts().id(user.username()).get()._accountId;
-      assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
-      gApi.accounts().id(user.username()).setActive(false);
+      int id = gApi.accounts().id(user.id().get()).get()._accountId;
+      assertThat(gApi.accounts().id(user.id().get()).getActive()).isTrue();
+      gApi.accounts().id(user.id().get()).setActive(false);
       accountIndexedCounter.assertReindexOf(user);
 
       // Inactive users may only be resolved by ID.
@@ -608,7 +608,7 @@
       assertThat(gApi.accounts().id(id).getActive()).isFalse();
 
       gApi.accounts().id(id).setActive(true);
-      assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
+      assertThat(gApi.accounts().id(user.id().get()).getActive()).isTrue();
       accountIndexedCounter.assertReindexOf(user);
     }
   }
@@ -778,9 +778,9 @@
 
   @Test
   public void deactivateNotActive() throws Exception {
-    int id = gApi.accounts().id(user.username()).get()._accountId;
-    assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
-    gApi.accounts().id(user.username()).setActive(false);
+    int id = gApi.accounts().id(user.id().get()).get()._accountId;
+    assertThat(gApi.accounts().id(user.id().get()).getActive()).isTrue();
+    gApi.accounts().id(user.id().get()).setActive(false);
     assertThat(gApi.accounts().id(id).getActive()).isFalse();
     ResourceConflictException thrown =
         assertThrows(
@@ -1078,7 +1078,7 @@
     TestAccount account = accountCreator.create(name("user"));
     EmailInput input = newEmailInput("test@example.com");
     requestScopeOperations.setApiUser(user.id());
-    assertThrows(AuthException.class, () -> gApi.accounts().id(account.username()).addEmail(input));
+    assertThrows(AuthException.class, () -> gApi.accounts().id(account.id().get()).addEmail(input));
   }
 
   @Test
@@ -1089,7 +1089,7 @@
     ResourceConflictException thrown =
         assertThrows(
             ResourceConflictException.class,
-            () -> gApi.accounts().id(user.username()).addEmail(input));
+            () -> gApi.accounts().id(user.id().get()).addEmail(input));
     assertThat(thrown)
         .hasMessageThat()
         .contains("Identity 'mailto:" + email + "' in use by another account");
@@ -1478,8 +1478,8 @@
 
   @Test
   public void adminCanSetNameOfOtherUser() throws Exception {
-    gApi.accounts().id(user.username()).setName("User McUserface");
-    assertThat(gApi.accounts().id(user.username()).get().name).isEqualTo("User McUserface");
+    gApi.accounts().id(user.id().get()).setName("User McUserface");
+    assertThat(gApi.accounts().id(user.id().get()).get().name).isEqualTo("User McUserface");
   }
 
   @Test
@@ -1487,7 +1487,7 @@
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
         AuthException.class,
-        () -> gApi.accounts().id(admin.username()).setName("Admin McAdminface"));
+        () -> gApi.accounts().id(admin.id().get()).setName("Admin McAdminface"));
   }
 
   @Test
@@ -1497,8 +1497,8 @@
         .allProjectsForUpdate()
         .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
         .update();
-    gApi.accounts().id(admin.username()).setName("Admin McAdminface");
-    assertThat(gApi.accounts().id(admin.username()).get().name).isEqualTo("Admin McAdminface");
+    gApi.accounts().id(admin.id().get()).setName("Admin McAdminface");
+    assertThat(gApi.accounts().id(admin.id().get()).get().name).isEqualTo("Admin McAdminface");
   }
 
   @Test
@@ -1932,8 +1932,8 @@
       // Add a new key
       sender.clear();
       String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), user.email());
-      gApi.accounts().id(user.username()).addSshKey(newKey);
-      info = gApi.accounts().id(user.username()).listSshKeys();
+      gApi.accounts().id(user.id().get()).addSshKey(newKey);
+      info = gApi.accounts().id(user.id().get()).listSshKeys();
       assertThat(info).hasSize(2);
       assertSequenceNumbers(info);
       accountIndexedCounter.assertReindexOf(user);
@@ -1945,8 +1945,8 @@
 
       // Delete key
       sender.clear();
-      gApi.accounts().id(user.username()).deleteSshKey(1);
-      info = gApi.accounts().id(user.username()).listSshKeys();
+      gApi.accounts().id(user.id().get()).deleteSshKey(1);
+      info = gApi.accounts().id(user.id().get()).listSshKeys();
       assertThat(info).hasSize(1);
       accountIndexedCounter.assertReindexOf(user);
 
@@ -1962,7 +1962,7 @@
   public void userCannotAddSshKeyToOtherAccount() throws Exception {
     String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), admin.email());
     requestScopeOperations.setApiUser(user.id());
-    assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).addSshKey(newKey));
+    assertThrows(AuthException.class, () -> gApi.accounts().id(admin.id().get()).addSshKey(newKey));
   }
 
   @Test
@@ -1971,7 +1971,7 @@
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
         ResourceNotFoundException.class,
-        () -> gApi.accounts().id(admin.username()).deleteSshKey(0));
+        () -> gApi.accounts().id(admin.id().get()).deleteSshKey(0));
   }
 
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
@@ -1982,7 +1982,7 @@
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       // admin can reindex any account
       requestScopeOperations.setApiUser(admin.id());
-      gApi.accounts().id(user.username()).index();
+      gApi.accounts().id(user.id().get()).index();
       accountIndexedCounter.assertReindexOf(user);
 
       // user can reindex own account
@@ -1992,7 +1992,7 @@
 
       // user cannot reindex any account
       AuthException thrown =
-          assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).index());
+          assertThrows(AuthException.class, () -> gApi.accounts().id(admin.id().get()).index());
       assertThat(thrown).hasMessageThat().contains("modify account not permitted");
     }
   }
@@ -2045,10 +2045,10 @@
     assertThat(accountQueryProvider.get().byDefault(name, true)).isEmpty();
 
     TestAccount foo1 = accountCreator.create(name + "-1");
-    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo1.id().get()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    gApi.accounts().id(foo2.username()).setActive(false);
+    gApi.accounts().id(foo2.id().get()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
 
     assertThat(accountQueryProvider.get().byDefault(name, true)).hasSize(2);
@@ -2081,7 +2081,7 @@
     return input;
   }
 
-  private EmailInput newEmailInput(String email) {
+  protected EmailInput newEmailInput(String email) {
     return newEmailInput(email, true);
   }
 
@@ -2097,7 +2097,7 @@
 
   @Test
   public void allGroupsForAnAdminAccountCanBeRetrieved() throws Exception {
-    List<GroupInfo> groups = gApi.accounts().id(admin.username()).getGroups();
+    List<GroupInfo> groups = gApi.accounts().id(admin.id().get()).getGroups();
     assertThat(groups)
         .comparingElementsUsing(getGroupToNameCorrespondence())
         .containsAtLeast("Anonymous Users", "Registered Users", "Administrators");
@@ -2135,13 +2135,13 @@
   @Test
   public void allGroupsForAUserAccountCanBeRetrieved() throws Exception {
     String username = name("user1");
-    accountOperations.newAccount().username(username).create();
+    Account.Id id = accountOperations.newAccount().username(username).create();
     AccountGroup.UUID groupID = groupOperations.newGroup().name("group").create();
     String group = groupOperations.group(groupID).get().name();
 
     gApi.groups().id(group).addMembers(username);
 
-    List<GroupInfo> allGroups = gApi.accounts().id(username).getGroups();
+    List<GroupInfo> allGroups = gApi.accounts().id(id.get()).getGroups();
     assertThat(allGroups)
         .comparingElementsUsing(getGroupToNameCorrespondence())
         .containsExactly("Anonymous Users", "Registered Users", group);
@@ -2629,7 +2629,7 @@
   public void adminCanGenerateNewHttpPasswordForUser() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
-    String newPassword = gApi.accounts().id(user.username()).generateHttpPassword();
+    String newPassword = gApi.accounts().id(user.id().get()).generateHttpPassword();
     assertThat(newPassword).isNotNull();
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
@@ -2639,7 +2639,7 @@
   public void userCannotGenerateNewHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
-        AuthException.class, () -> gApi.accounts().id(admin.username()).generateHttpPassword());
+        AuthException.class, () -> gApi.accounts().id(admin.id().get()).generateHttpPassword());
   }
 
   @Test
@@ -2654,7 +2654,7 @@
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
         AuthException.class,
-        () -> gApi.accounts().id(admin.username()).setHttpPassword("my-new-password"));
+        () -> gApi.accounts().id(admin.id().get()).setHttpPassword("my-new-password"));
   }
 
   @Test
@@ -2670,7 +2670,7 @@
   public void userCannotRemoveHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
-        AuthException.class, () -> gApi.accounts().id(admin.username()).setHttpPassword(null));
+        AuthException.class, () -> gApi.accounts().id(admin.id().get()).setHttpPassword(null));
   }
 
   @Test
@@ -2678,7 +2678,7 @@
     requestScopeOperations.setApiUser(admin.id());
     String httpPassword = "new-password-for-user";
     sender.clear();
-    assertThat(gApi.accounts().id(user.username()).setHttpPassword(httpPassword))
+    assertThat(gApi.accounts().id(user.id().get()).setHttpPassword(httpPassword))
         .isEqualTo(httpPassword);
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
@@ -2688,7 +2688,7 @@
   public void adminCanRemoveHttpPasswordForUser() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
-    assertThat(gApi.accounts().id(user.username()).setHttpPassword(null)).isNull();
+    assertThat(gApi.accounts().id(user.id().get()).setHttpPassword(null)).isNull();
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).body()).contains("HTTP password was deleted");
   }
@@ -3590,9 +3590,9 @@
               extensionRegistry.newRegistration().add(accountIndexedCounter)) {
             Map<String, GpgKeyInfo> gpgKeys =
                 gApi.accounts()
-                    .id(account.username())
+                    .id(account.id().get())
                     .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
-            accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
+            accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.id().get()).get());
             return gpgKeys;
           }
         });
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 9b77b01..b55a535 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -202,7 +202,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.accounts().id("admin").signAgreement(caAutoVerify.getName()));
+            () -> gApi.accounts().id(admin.id().get()).signAgreement(caAutoVerify.getName()));
     assertThat(thrown).hasMessageThat().contains("not allowed to enter contributor agreement");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
index a442ddd..e3380c0 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -28,7 +28,7 @@
   @Test
   public void getDiffPreferences() throws Exception {
     DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
-    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertPrefs(o, d);
   }
 
@@ -62,13 +62,13 @@
     i.matchBrackets ^= true;
     i.lineWrapping ^= true;
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertPrefs(o, i);
 
     // Partially fill input record
     i = new DiffPreferencesInfo();
     i.tabSize = 42;
-    DiffPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo a = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertPrefs(a, o, "tabSize");
     assertThat(a.tabSize).isEqualTo(42);
   }
@@ -85,7 +85,7 @@
     update.fontSize = newFontSize;
     gApi.config().server().setDefaultDiffPreferences(update);
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).getDiffPreferences();
 
     // assert configured defaults
     assertThat(o.lineLength).isEqualTo(newLineLength);
@@ -104,29 +104,29 @@
     update.lineLength = configuredDefaultLineLength;
     gApi.config().server().setDefaultDiffPreferences(update);
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertThat(o.lineLength).isEqualTo(configuredDefaultLineLength);
     assertPrefs(o, d, "lineLength");
 
     int newLineLength = configuredDefaultLineLength + 10;
     DiffPreferencesInfo i = new DiffPreferencesInfo();
     i.lineLength = newLineLength;
-    DiffPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo a = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertThat(a.lineLength).isEqualTo(newLineLength);
     assertPrefs(a, d, "lineLength");
 
-    a = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    a = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertThat(a.lineLength).isEqualTo(newLineLength);
     assertPrefs(a, d, "lineLength");
 
     // overwrite the configured default with original hard-coded default
     i = new DiffPreferencesInfo();
     i.lineLength = d.lineLength;
-    a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
+    a = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertThat(a.lineLength).isEqualTo(d.lineLength);
     assertPrefs(a, d, "lineLength");
 
-    a = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    a = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertThat(a.lineLength).isEqualTo(d.lineLength);
     assertPrefs(a, d, "lineLength");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
index f142c08..6802333 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -25,7 +25,7 @@
 public class EditPreferencesIT extends AbstractDaemonTest {
   @Test
   public void getSetEditPreferences() throws Exception {
-    EditPreferencesInfo out = gApi.accounts().id(admin.id().toString()).getEditPreferences();
+    EditPreferencesInfo out = gApi.accounts().id(admin.id().get()).getEditPreferences();
 
     assertThat(out.lineLength).isEqualTo(100);
     assertThat(out.indentUnit).isEqualTo(2);
@@ -58,7 +58,7 @@
     out.autoCloseBrackets = true;
     out.showBase = true;
 
-    EditPreferencesInfo info = gApi.accounts().id(admin.id().toString()).setEditPreferences(out);
+    EditPreferencesInfo info = gApi.accounts().id(admin.id().get()).setEditPreferences(out);
 
     assertEditPreferences(info, out);
 
@@ -66,7 +66,7 @@
     EditPreferencesInfo in = new EditPreferencesInfo();
     in.tabSize = 42;
 
-    info = gApi.accounts().id(admin.id().toString()).setEditPreferences(in);
+    info = gApi.accounts().id(admin.id().get()).setEditPreferences(in);
 
     out.tabSize = in.tabSize;
     assertEditPreferences(info, out);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index eed9de8..4a28303 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -53,7 +53,7 @@
 
   @Test
   public void getAndSetPreferences() throws Exception {
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).getPreferences();
     assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
     assertThat(o.my)
         .containsExactly(
@@ -91,7 +91,7 @@
     i.changeTable = new ArrayList<>();
     i.changeTable.add("Status");
 
-    o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    o = gApi.accounts().id(user42.id().get()).setPreferences(i);
     assertPrefs(o, i, "my");
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
@@ -109,7 +109,7 @@
     update.changesPerPage = newChangesPerPage;
     gApi.config().server().setDefaultPreferences(update);
 
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).getPreferences();
 
     // assert configured defaults
     assertThat(o.changesPerPage).isEqualTo(newChangesPerPage);
@@ -126,29 +126,29 @@
     update.changesPerPage = configuredChangesPerPage;
     gApi.config().server().setDefaultPreferences(update);
 
-    GeneralPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(admin.id().get()).getPreferences();
     assertThat(o.changesPerPage).isEqualTo(configuredChangesPerPage);
     assertPrefs(o, d, "my", "changeTable", "changesPerPage");
 
     int newChangesPerPage = configuredChangesPerPage * 2;
     GeneralPreferencesInfo i = new GeneralPreferencesInfo();
     i.changesPerPage = newChangesPerPage;
-    GeneralPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setPreferences(i);
+    GeneralPreferencesInfo a = gApi.accounts().id(admin.id().get()).setPreferences(i);
     assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
-    a = gApi.accounts().id(admin.id().toString()).getPreferences();
+    a = gApi.accounts().id(admin.id().get()).getPreferences();
     assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
     // overwrite the configured default with original hard-coded default
     i = new GeneralPreferencesInfo();
     i.changesPerPage = d.changesPerPage;
-    a = gApi.accounts().id(admin.id().toString()).setPreferences(i);
+    a = gApi.accounts().id(admin.id().get()).setPreferences(i);
     assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
-    a = gApi.accounts().id(admin.id().toString()).getPreferences();
+    a = gApi.accounts().id(admin.id().get()).getPreferences();
     assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
   }
@@ -162,7 +162,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+            () -> gApi.accounts().id(user42.id().get()).setPreferences(i));
     assertThat(thrown).hasMessageThat().contains("name for menu item is required");
   }
 
@@ -175,7 +175,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+            () -> gApi.accounts().id(user42.id().get()).setPreferences(i));
     assertThat(thrown).hasMessageThat().contains("URL for menu item is required");
   }
 
@@ -185,7 +185,7 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem(" name\t", " url\t", " _blank\t", " id\t"));
 
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).setPreferences(i);
     assertThat(o.my).containsExactly(new MenuItem("name", "url", "_blank", "id"));
   }
 
@@ -197,7 +197,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+            () -> gApi.accounts().id(user42.id().get()).setPreferences(i));
     assertThat(thrown)
         .hasMessageThat()
         .contains("Unsupported download scheme: " + i.downloadScheme);
@@ -211,10 +211,10 @@
       GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
       i.downloadScheme = schemeName;
 
-      GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
+      GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).setPreferences(i);
       assertThat(o.downloadScheme).isEqualTo(schemeName);
 
-      o = gApi.accounts().id(user42.id().toString()).getPreferences();
+      o = gApi.accounts().id(user42.id().get()).getPreferences();
       assertThat(o.downloadScheme).isEqualTo(schemeName);
     }
   }
@@ -225,7 +225,7 @@
     // becomes unsupported.
     setDownloadScheme();
 
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).getPreferences();
     assertThat(o.downloadScheme).isNull();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 7183f9e..9bb1aa4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -169,17 +169,75 @@
   }
 
   @Test
-  public void submitWithRebaseMergeCommit() throws Throwable {
+  public void submitMergeCommitThatDependsOnNormalChangeViaTheFirstParent() throws Throwable {
     /*
-       *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
+           *  merge created by Gerrit to integrate change 1 and change 2
+          /|
+         / |
+         | |
+         * | change3 (new tip)
+         | |
+         | * change2 (merge)
+         | |\
+         | | |
+         | * | change1
+          \|/
+           * initialHead
+    */
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
+
+    PushOneCommit change2Push =
+        pushFactory.create(admin.newIdent(), testRepo, "Merge to master", "m.txt", "");
+    change2Push.setParents(ImmutableList.of(change1.getCommit(), initialHead));
+    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change3 = createChange("New tip", "b.txt", "");
+
+    approve(change3.getChangeId());
+    submit(change3.getChangeId());
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    submit(change2.getChangeId());
+
+    RevCommit newHead = projectOperations.project(project).getHead("master");
+    assertThat(newHead.getParentCount()).isEqualTo(2);
+
+    RevCommit headParent1 = parse(newHead.getParent(0).getId());
+    RevCommit headParent2 = parse(newHead.getParent(1).getId());
+
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change3.getChangeId(), 2, headParent1.getId());
+    } else {
+      assertThat(change3.getCommit().getId()).isEqualTo(headParent1.getId());
+    }
+    assertThat(headParent1.getParentCount()).isEqualTo(1);
+    assertThat(headParent1.getParent(0)).isEqualTo(initialHead);
+
+    assertThat(headParent2.getId()).isEqualTo(change2.getCommit().getId());
+    assertThat(headParent2.getParentCount()).isEqualTo(2);
+
+    RevCommit headGrandparent1 = parse(headParent2.getParent(0).getId());
+    RevCommit headGrandparent2 = parse(headParent2.getParent(1).getId());
+
+    assertThat(headGrandparent1.getId()).isEqualTo(change1.getCommit().getId());
+    assertThat(headGrandparent2.getId()).isEqualTo(initialHead.getId());
+  }
+
+  @Test
+  public void submitMergeCommitThatDependsOnNormalChangeViaTheSecondParent() throws Throwable {
+    /*
+       *  merge created by Gerrit to integrate change 1 and change 2
        |\
-       | *   Merge branch 'master' into origin/master
+       | * change2 (merge)
        | |\
-       | | * SHA Added a
+       | | * change1
        | |/
-       * | Before
+       * | change3 (new tip)
        |/
-       * Initial empty repository
+       * initialHead
     */
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
@@ -190,7 +248,7 @@
     PushOneCommit.Result change2 = change2Push.to("refs/for/master");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Before", "b.txt", "");
+    PushOneCommit.Result change3 = createChange("New tip", "b.txt", "");
 
     approve(change3.getChangeId());
     submit(change3.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 6dfa82b..10f4942 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -476,17 +476,17 @@
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
     reviewChange(changeIdReviewed, foo1);
-    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo1.id().get()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
     reviewChange(changeIdReviewed, foo2);
-    assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isTrue();
 
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().id(foo2.username()).setActive(false);
+    gApi.accounts().id(foo2.id().get()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
     assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
     assertReviewers(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java
index 98f5716..8311d96 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java
@@ -150,15 +150,15 @@
 
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
-    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo1.id().get()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isTrue();
 
     assertReviewers(suggestReviewers(name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().id(foo2.username()).setActive(false);
+    gApi.accounts().id(foo2.id().get()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
     assertReviewers(suggestReviewers(name), ImmutableList.of(foo1), ImmutableList.of());
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index b150491..3664cdc 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -106,7 +106,7 @@
     gApi.accounts().id(user.id().get()).setActive(false);
 
     requestScopeOperations.setApiUser(user.id());
-    assertThat(gApi.accounts().id("self").getActive()).isFalse();
+    assertThat(gApi.accounts().self().getActive()).isFalse();
 
     Result result = resolveAsResult("self");
     assertThat(result.asIdSet()).containsExactly(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
index 65b1d4f..f17013d 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
@@ -39,7 +39,7 @@
     // Set user preference to receive only plaintext content
     GeneralPreferencesInfo i = new GeneralPreferencesInfo();
     i.emailFormat = EmailFormat.PLAINTEXT;
-    gApi.accounts().id(admin.id().toString()).setPreferences(i);
+    gApi.accounts().id(admin.id().get()).setPreferences(i);
 
     // Create change as admin and review as user
     PushOneCommit.Result r = createChange();
@@ -57,7 +57,7 @@
     // Reset user preference
     requestScopeOperations.setApiUser(admin.id());
     i.emailFormat = EmailFormat.HTML_PLAINTEXT;
-    gApi.accounts().id(admin.id().toString()).setPreferences(i);
+    gApi.accounts().id(admin.id().get()).setPreferences(i);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 6c4d1e4..0ca9478 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/entities/converter/HumanCommentProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/HumanCommentProtoConverterTest.java
new file mode 100644
index 0000000..a6aaf36
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/HumanCommentProtoConverterTest.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2023 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.entities.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
+import java.time.Instant;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class HumanCommentProtoConverterTest {
+  private static final ObjectId VALID_OBJECT_ID =
+      ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+  private final HumanCommentProtoConverter converter = HumanCommentProtoConverter.INSTANCE;
+
+  @Test
+  public void fileLevelCommentWithAllOptionalFields() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", "a.txt", 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ true);
+    orig.tag = "tag";
+    orig.setCommitId(VALID_OBJECT_ID);
+    orig.setRealAuthor(Account.id(271));
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+
+  @Test
+  public void patchsetLevelComment() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", PATCHSET_LEVEL, 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ false);
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+
+  @Test
+  public void lineComment() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", "a.txt", 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ true);
+    orig.setLineNbrAndRange(7, null);
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+
+  @Test
+  public void rangeComment() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", "a.txt", 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ true);
+    orig.setRange(new CommentRange(2, 3, 5, 7));
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+
+  @Test
+  public void extensionRangeComment() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", "a.txt", 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ false);
+    com.google.gerrit.extensions.client.Comment.Range range =
+        new com.google.gerrit.extensions.client.Comment.Range();
+    range.startLine = 2;
+    range.startCharacter = 3;
+    range.endLine = 5;
+    range.endCharacter = 7;
+    orig.setLineNbrAndRange(null, range);
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index d40f2a1..f33dc8d 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -127,11 +127,11 @@
           "ssh://%s@" + sshAddress.getHostName() + ":" + sshAddress.getPort() + "/" + project.get();
 
       // Admin user was already created by the base class
-      setUpUserAuthentication(admin.username());
+      setUpUserAuthentication(admin.username(), admin.id().get());
 
       // Create non-admin user
       TestAccount user = accountCreator.user1();
-      setUpUserAuthentication(user.username());
+      setUpUserAuthentication(user.username(), user.id().get());
 
       // Prepare data for new change on master branch
       ChangeInput in = new ChangeInput(project.get(), "master", "Test public change");
@@ -271,7 +271,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       // Get authenticated Git/HTTP URL
       String urlWithCredentials =
@@ -336,7 +336,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       // Get authenticated Git/HTTP URL
       String urlWithCredentials =
@@ -405,7 +405,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       // Get authenticated Git/HTTP URL
       String urlWithCredentials =
@@ -488,7 +488,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       String url = config.getString("gerrit", null, "canonicalweburl");
 
@@ -560,9 +560,9 @@
     }
   }
 
-  private void setUpUserAuthentication(String username) throws Exception {
+  private void setUpUserAuthentication(String username, int accountId) throws Exception {
     // Assign HTTP password to user
-    gApi.accounts().id(username).setHttpPassword(ADMIN_PASSWORD);
+    gApi.accounts().id(accountId).setHttpPassword(ADMIN_PASSWORD);
 
     // Generate private/public key for user
     execute(
@@ -573,7 +573,7 @@
 
     // Read the content of generated public key and add it for the user in Gerrit
     gApi.accounts()
-        .id(username)
+        .id(accountId)
         .addSshKey(
             new String(
                 java.nio.file.Files.readAllBytes(
diff --git a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
index ad8a486..78fd35f 100644
--- a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
+++ b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
@@ -65,7 +65,7 @@
     ctx.getInjector().injectMembers(this);
 
     TestAccount user = accountCreator.user1();
-    gApi.accounts().id(user.username()).setHttpPassword(PASSWORD);
+    gApi.accounts().id(user.id().get()).setHttpPassword(PASSWORD);
 
     String canonical = config.getString("gerrit", null, "canonicalweburl");
     repoUrl =
diff --git a/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java b/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
index c42f00d..6ccc7cd 100644
--- a/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
+++ b/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
@@ -55,7 +55,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       // Get authenticated Git/HTTP URL
       String urlWithCredentials =
diff --git a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index c720905..b2be85f 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -154,7 +154,7 @@
             .add(String.format("id_rsa_%s", admin.username()))
             .build());
     gApi.accounts()
-        .id(admin.username())
+        .id(admin.id().get())
         .addSshKey(
             new String(
                 java.nio.file.Files.readAllBytes(
diff --git a/javatests/com/google/gerrit/integration/ssh/NoShellIT.java b/javatests/com/google/gerrit/integration/ssh/NoShellIT.java
index 2bbbf1a..f25db6e 100644
--- a/javatests/com/google/gerrit/integration/ssh/NoShellIT.java
+++ b/javatests/com/google/gerrit/integration/ssh/NoShellIT.java
@@ -66,7 +66,7 @@
             .add(String.format("id_rsa_%s", "admin"))
             .build());
     gApi.accounts()
-        .id("admin")
+        .id(admin.id().get())
         .addSshKey(
             new String(
                 java.nio.file.Files.readAllBytes(
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index c530c79..88a3afe 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -667,8 +667,8 @@
     // we load AccountStates from the cache after reading documents from the index
     // which means we always read fresh data when matching.
     //
-    // Reindex document
-    gApi.accounts().id(user1.username).index();
+    // Reindex account document
+    gApi.accounts().id(user1._accountId).index();
     assertQuery("name:" + quote(user1.name));
     assertQuery("name:" + quote(newName), user1);
   }
diff --git a/plugins/replication b/plugins/replication
index 8fd3c27..c7c36d6 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 8fd3c271ce0a21480e3d04da5ad2112efea3bedf
+Subproject commit c7c36d62cf61d1588e9cdeab3e89b77a65f44c79
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 31c2b660..12c4202 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -370,15 +370,6 @@
 
   @query('#secondaryActions') secondaryActions?: HTMLElement;
 
-  // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
-  // properties are replaced with enums everywhere and remove them from
-  // the GrChangeActions class
-  ActionType = ActionType;
-
-  ChangeActions = ChangeActions;
-
-  RevisionActions = RevisionActions;
-
   @state() change?: ParsedChangeInfo;
 
   @state() actions: ActionNameToActionInfoMap = {};
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index b953eec..b6a1a6a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -39,7 +39,7 @@
   ReviewInput,
   TopicName,
 } from '../../../types/common';
-import {ActionType} from '../../../api/change-actions';
+import {ActionType, RevisionActions} from '../../../api/change-actions';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -409,8 +409,8 @@
       );
       assert.isOk(buttonEl);
       element.setActionHidden(
-        element.ActionType.REVISION,
-        element.RevisionActions.SUBMIT,
+        ActionType.REVISION,
+        RevisionActions.SUBMIT,
         true
       );
       assert.lengthOf(element.hiddenActions, 1);
@@ -419,8 +419,8 @@
       assert.isNotOk(buttonEl);
 
       element.setActionHidden(
-        element.ActionType.REVISION,
-        element.RevisionActions.SUBMIT,
+        ActionType.REVISION,
+        RevisionActions.SUBMIT,
         false
       );
       await element.updateComplete;
@@ -1235,7 +1235,7 @@
     test('custom actions', async () => {
       // Add a button with the same key as a server-based one to ensure
       // collisions are taken care of.
-      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      const key = element.addActionButton(ActionType.REVISION, 'Bork!');
       const keyTapped = mockPromise();
       element.addEventListener(key + '-tap', async e => {
         assert.equal(
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 532052d..7c893f2 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -1078,7 +1078,7 @@
         return '(0)';
       }
     }
-    if (!this.generatedSuggestion) {
+    if (this.generatedSuggestion) {
       return '(1)';
     } else {
       return '(0)';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index f0143a6..523cc59 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -9,10 +9,8 @@
 import {
   ActionPriority,
   ActionType,
-  ChangeActions,
   ChangeActionsPluginApi,
   PrimaryActionKey,
-  RevisionActions,
 } from '../../../api/change-actions';
 import {PropertyDeclaration} from 'lit';
 import {JsApiService} from './gr-js-api-types';
@@ -28,9 +26,6 @@
 
 // This interface is required to avoid circular dependencies between files;
 export interface GrChangeActionsElement extends Element {
-  RevisionActions?: Record<string, string>;
-  ChangeActions: Record<string, string>;
-  ActionType: Record<string, string>;
   primaryActionKeys: string[];
   hideQuickApproveAction(): void;
   setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
@@ -58,12 +53,6 @@
 export class GrChangeActionsInterface implements ChangeActionsPluginApi {
   private el?: GrChangeActionsElement;
 
-  RevisionActions = RevisionActions;
-
-  ChangeActions = ChangeActions;
-
-  ActionType = ActionType;
-
   private readonly reporting = getAppContext().reportingService;
 
   constructor(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index e557ca8..0cc4f7f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -69,14 +69,6 @@
       testResolver(pluginLoaderToken).loadPlugins([]);
     });
 
-    test('property existence', () => {
-      const properties = ['ActionType', 'ChangeActions', 'RevisionActions'];
-      for (const p of properties) {
-        // Have to type as any to prevent 'has no index signature.'
-        assert.deepEqual((changeActions as any)[p], (element as any)[p]);
-      }
-    });
-
     test('add/remove primary action keys', () => {
       element.primaryActionKeys = [];
       changeActions.addPrimaryActionKey('foo' as PrimaryActionKey);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 1501205..c488dfb 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -358,8 +358,12 @@
   }
 
   private handleTabKey(e: KeyboardEvent) {
-    // Tab should have normal behavior if the picker is closed.
-    if (!this.isDropdownVisible()) {
+    // Tab should have normal behavior if the picker is closed, or "open" but
+    // with no results.
+    if (
+      !this.isDropdownVisible() ||
+      this.getVisibleDropdown().getCurrentText() === ''
+    ) {
       return;
     }
     e.preventDefault();
diff --git a/proto/entities.proto b/proto/entities.proto
index 3412291..335db62 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -311,3 +311,64 @@
   }
   optional EditPreferencesInfo edit_preferences_info = 3;
 }
+
+// Next Id: 13
+message HumanComment {
+  // Required. Note that the equivalent Java struct does not contain the change
+  // ID, so we keep the same format here.
+  optional int32 patchset_id = 1;
+  optional ObjectId dest_commit_id = 2;
+  // Required.
+  optional Account_Id account_id = 3;
+  optional Account_Id real_author = 4;
+
+  // Next Id: 5
+  message InFilePosition {
+    optional string file_path = 1;
+    enum Side {
+      // Should match the logic in
+      // http://google3/third_party/java_src/gerritcodereview/gerrit/java/com/google/gerrit/extensions/client/Side.java?rcl=579772037&l=24
+      PARENT = 0;
+      REVISION = 1;
+    }
+    // Default should match
+    // http://google3/third_party/java_src/gerritcodereview/gerrit/Documentation/rest-api-changes.txt?l=7423
+    optional Side side = 2 [default = REVISION];
+    message Range {
+      // 1-based
+      optional int32 start_line = 1;
+      // 0-based
+      optional int32 start_char = 2;
+      // 1-based
+      optional int32 end_line = 3;
+      // 0-based
+      optional int32 end_char = 4;
+    }
+    // If neither range nor line number set, the comment is on the file level. It is possible
+    // (though not required) for both values to be set. in this case, it is expected that the line
+    // number is identical to the range's end line.
+    optional Range position_range = 3;
+    // 1-based
+    optional int32 line_number = 4;
+  }
+
+  // If not set, the comment is on the patchset level.
+  optional InFilePosition in_file_position = 5;
+
+  // Required.
+  optional string comment_text = 6;
+  // Might be set by the user while creating the draft.
+  // See http://go/gerrit-rest-api-change#comment-info.
+  optional string tag = 7;
+  optional bool unresolved = 8 [default = false];
+
+  // Required.
+  optional string comment_uuid = 9;
+  // Required.
+  optional string parent_comment_uuid = 10;
+
+  // Required. Epoch millis.
+  optional fixed64 written_on_millis = 11;
+  // Required.
+  optional string server_id = 12;
+}
\ No newline at end of file